editmamei 1.4.0 → 1.5.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.
package/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
  [![License](https://img.shields.io/badge/License-FSL--1.1--MIT-blue.svg)](LICENSE.md)
10
10
  [![Platform](https://img.shields.io/badge/Platform-Windows%20%7C%20macOS-lightgrey.svg)](docs/installation.md)
11
11
 
12
- Editmamei is a Model Context Protocol (MCP) server that drives the Adobe Photoshop you already have. You describe the edit in plain words, your AI assistant plans the steps, and your own copy of Photoshop carries them out with its standard adjustment layers, masks, selections, and filters. The AI directs and Photoshop edits. No generative model touches your pixels, and no image is uploaded to perform an edit.
12
+ Editmamei is a Model Context Protocol (MCP) server that drives desktop Adobe Photoshop. You describe the edit in plain words, your AI assistant plans the steps, and Photoshop carries them out with its standard adjustment layers, masks, selections, and filters. The AI directs and Photoshop edits. No generative model touches your pixels, and no image is uploaded to perform an edit.
13
13
 
14
14
  It serves photographers and retouchers who want to edit by conversation just as much as developers wiring Photoshop into an AI pipeline. To learn more or get started, visit **[editmamei.com](https://editmamei.com)**.
15
15
 
@@ -47,7 +47,7 @@ Then restart your AI client and ask it: _"Is Photoshop connected?"_
47
47
 
48
48
  Prefer not to use a terminal? Claude Desktop users can install the one-click [`.mcpb` extension](https://github.com/editmamei/editmamei/releases/latest/download/editmamei.mcpb) directly (no system Node required, Claude Desktop ships its own runtime). Full walkthrough in the [install guide](docs/installation.md).
49
49
 
50
- **Requirements:** Adobe Photoshop 2026 (v27.x), Windows 10/11 or macOS 13+, Node.js 22+ (for the npm path), and an MCP-compatible AI client (Claude Desktop, Cursor, Claude Code).
50
+ **Requirements:** Adobe Photoshop 2026 (v27.x), Windows 10/11 or macOS 13+, Node.js 22+ (for the npm path), and an MCP-compatible AI client (Claude Desktop, Cursor, Claude Code). Editmamei ships no model of its own and runs on whichever client you connect, so every edit spends that client's tokens.
51
51
 
52
52
  ## A session, end to end
53
53
 
@@ -75,7 +75,7 @@ These are the choices that shape the surface, and the reason an AI assistant can
75
75
 
76
76
  ## Tool surface
77
77
 
78
- **82 tools across 16 capability groups** (62 Community, 20 Pro). Every tool is namespaced `ps_*` and discoverable at runtime via `tools/list`. Community tools ship in both editions; Pro tools unlock with a license.
78
+ **83 tools across 16 capability groups** (62 Community, 21 Pro). Every tool is namespaced `ps_*` and discoverable at runtime via `tools/list`. Community tools ship in both editions; Pro tools unlock with a license.
79
79
 
80
80
  | Group | Edition | Tools |
81
81
  | --- | --- | --- |
@@ -94,7 +94,7 @@ These are the choices that shape the surface, and the reason an AI assistant can
94
94
  | **AI selection** | Community | `select_subject` · `select_sky` · `replace_sky` |
95
95
  | **Automation** | Community | `sequence` |
96
96
  | **AI selection** | Pro | `select_subject_instance` · `select_object` |
97
- | **Filters** | Pro | `apply_camera_raw` |
97
+ | **Filters** | Pro | `apply_camera_raw` · `develop_raw` |
98
98
  | **Layers (warp)** | Pro | `warp_layer` |
99
99
  | **Perception** | Pro | `edit_object` · `add_text_to_object` · `resolve_placement` |
100
100
  | **Face mesh** | Pro | `detect_landmarks` · `select_face_feature` |
@@ -132,7 +132,7 @@ Settings live in `~/.editmamei/settings.json`, managed by the `editmamei config`
132
132
 
133
133
  ```bash
134
134
  editmamei config list
135
- editmamei config set telemetry.usage false # turn off anonymous usage telemetry
135
+ editmamei config set telemetry.usage false # turn off usage telemetry
136
136
  editmamei config set ps_path "/path/to/Photoshop"
137
137
  ```
138
138
 
@@ -140,7 +140,7 @@ editmamei config set ps_path "/path/to/Photoshop"
140
140
 
141
141
  ## Privacy
142
142
 
143
- Editmamei runs on your computer and edits in your own Photoshop. No image content, document data, or file paths are sent to Editmamei's servers. It reports anonymous, content-free usage telemetry (on by default, turn it off with `editmamei config set telemetry.usage false`). When your AI assistant needs to see a result, Editmamei sends it a downscaled preview, the same as dropping a photo into a chat with that assistant. Every field is documented in [privacy.md](docs/privacy.md).
143
+ Editmamei runs on your computer and edits in desktop Photoshop. No image content, document data, or file paths are sent to Editmamei's servers. It reports content-free usage telemetry (on by default, turn it off with `editmamei config set telemetry.usage false`). When your AI assistant needs to see a result, Editmamei sends it a downscaled preview, the same as dropping a photo into a chat with that assistant. Every field is documented in [privacy.md](docs/privacy.md), including [your rights, and the legal basis](docs/privacy.md#your-rights-and-the-legal-basis).
144
144
 
145
145
  The source in this repository is the same code published to npm, so none of the above has to be taken on trust.
146
146
 
Binary file
Binary file
package/dist/cli/help.js CHANGED
@@ -33,7 +33,7 @@ Install options:
33
33
 
34
34
  Config examples:
35
35
  editmamei config list Show all settings
36
- editmamei config set telemetry.usage false Opt out of anonymous usage telemetry
36
+ editmamei config set telemetry.usage false Opt out of usage telemetry
37
37
  editmamei config set telemetry.diagnostics true Opt in to sanitized diagnostic detail
38
38
 
39
39
  Report options:
@@ -8,13 +8,14 @@ import { groupOf, GROUPS } from './tool-groups.js';
8
8
  import { EDITION } from '../edition.js';
9
9
  import { VERSION } from '../version.js';
10
10
  import { Session } from './session.js';
11
- import { SessionLog, classifyError, NO_ERROR_TEXT_CLASS } from '../utils/session-log.js';
11
+ import { SessionLog, classifyError, computeResultBytes, NO_ERROR_TEXT_CLASS, } from '../utils/session-log.js';
12
12
  import { loadSettings, applyTelemetryEnvOverrides, applyUpdateCheckEnvOverride, } from './settings.js';
13
13
  import { TelemetryClient } from '../telemetry/client.js';
14
14
  import { resolveInstallChannel } from '../install-channel.js';
15
- import { checkForUpdate, shouldCheckForUpdate } from '../update/check.js';
15
+ import { checkForUpdateWithStatus, shouldCheckForUpdate, } from '../update/check.js';
16
16
  import { previousSessionFailureCounts, relevantFixes } from '../update/session-fixes.js';
17
17
  import { join } from 'node:path';
18
+ import { createHash } from 'node:crypto';
18
19
  import { GoSnippetClient, coreBinaryName } from '../api/snippet-client.js';
19
20
  import { isProEntitled } from '../license/entitlement.js';
20
21
  import { createPingLicenseRefresher } from '../license/ping-refresh.js';
@@ -42,9 +43,20 @@ function warnLogScriptOnErrorOnce(logger) {
42
43
  export function __resetLogScriptOnErrorWarnForTests() {
43
44
  logScriptOnErrorWarned = false;
44
45
  }
45
- const FIRST_RUN_DISCLOSURE = 'First run: Editmamei collects anonymous, content-free usage telemetry (tool name, ' +
46
- 'success, duration, version/edition/OS/PS-version, install channel) to find what breaks. ' +
47
- 'It never sends image content, file paths, or personal data. Opt out anytime: ' +
46
+ function hashRetryKey(tool, args) {
47
+ try {
48
+ const json = JSON.stringify({ tool, args });
49
+ return createHash('sha256').update(json).digest('hex');
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ }
55
+ export const FIRST_RUN_DISCLOSURE = 'First run: Editmamei collects content-free usage telemetry (tool name, ' +
56
+ 'success, duration, bytes returned, version/edition/OS/PS-version, install channel, ' +
57
+ 'which AI client connected, Node/OS/architecture versions, and per-session counts like ' +
58
+ 'edits made and retries) to find what breaks. It never sends image content, file paths, ' +
59
+ 'or tool arguments. The only identifier is a random install ID. Opt out anytime: ' +
48
60
  '`editmamei config set telemetry.usage false` (or edit ~/.editmamei/settings.json). ' +
49
61
  'Opt in to sanitized diagnostics: `editmamei config set telemetry.diagnostics true`.';
50
62
  export const BACKGROUND_VERSION_PROBE_TIMEOUT_MS = 3_000;
@@ -62,13 +74,17 @@ export class EditmameiServer {
62
74
  }
63
75
  psVersion = null;
64
76
  lastPingReachedPs = null;
77
+ lastTelemetryCallKeyHash = null;
78
+ lastPsLocale = null;
79
+ lastDocDepth = null;
80
+ lastDocMode = null;
65
81
  liveVersionResolutionAttempted = false;
66
82
  updateInfo = null;
67
83
  updateCheck = null;
68
84
  updateNoticeShown = false;
69
85
  snippetClient = new GoSnippetClient();
70
86
  refreshLicenseOnPing = createPingLicenseRefresher();
71
- constructor() {
87
+ constructor(opts = {}) {
72
88
  this.logger = new Logger('EditmameiServer');
73
89
  this.session = new Session();
74
90
  this.sessionLog = new SessionLog(this.session.getSessionId());
@@ -84,10 +100,18 @@ export class EditmameiServer {
84
100
  });
85
101
  if (created)
86
102
  this.logger.info(FIRST_RUN_DISCLOSURE);
87
- if (shouldCheckForUpdate(effectiveSettings.update_check)) {
88
- this.updateCheck = checkForUpdate().then((info) => {
89
- this.updateInfo = info;
90
- });
103
+ const shouldCheckForUpdateFn = opts.shouldCheckForUpdate ?? shouldCheckForUpdate;
104
+ const checkForUpdateWithStatusFn = opts.checkForUpdateWithStatus ?? checkForUpdateWithStatus;
105
+ if (shouldCheckForUpdateFn(effectiveSettings.update_check)) {
106
+ this.updateCheck = checkForUpdateWithStatusFn()
107
+ .then((result) => {
108
+ this.updateInfo = result.info;
109
+ if (result.status === 'newer')
110
+ this.telemetry.setBehindLatest(true);
111
+ else if (result.status === 'current')
112
+ this.telemetry.setBehindLatest(false);
113
+ })
114
+ .catch(() => undefined);
91
115
  }
92
116
  warnLogScriptOnErrorOnce(this.logger);
93
117
  this.toolRegistry = new ToolRegistry({
@@ -97,22 +121,33 @@ export class EditmameiServer {
97
121
  this.resolveLiveVersionInBackground();
98
122
  }
99
123
  }
100
- void this.sessionLog.append({
124
+ const resultBytes = computeResultBytes(entry.result);
125
+ void this.sessionLog
126
+ .append({
101
127
  tool: entry.tool,
102
128
  args: entry.args,
103
129
  success: entry.success,
104
130
  duration_ms: entry.duration_ms,
105
131
  ...(entry.error ? { error: entry.error } : {}),
106
- }, entry.result);
132
+ }, entry.result, resultBytes)
133
+ .catch((err) => {
134
+ this.logger.debug(`session log append failed: ${String(err)}`);
135
+ });
107
136
  const telemetrySuccess = entry.tool === 'ps_ping' && this.lastPingReachedPs === false ? false : entry.success;
108
137
  const isPingDowngrade = entry.tool === 'ps_ping' && !telemetrySuccess && entry.error === undefined;
109
138
  const errorClass = classifyError(entry.error) ??
110
139
  (telemetrySuccess ? null : isPingDowngrade ? 'ps_not_running' : NO_ERROR_TEXT_CLASS);
140
+ const telemetryCallKeyHash = hashRetryKey(entry.tool, entry.args);
141
+ const isTelemetryRetry = telemetryCallKeyHash !== null && telemetryCallKeyHash === this.lastTelemetryCallKeyHash;
142
+ if (telemetryCallKeyHash !== null)
143
+ this.lastTelemetryCallKeyHash = telemetryCallKeyHash;
111
144
  this.telemetry.recordCall({
112
145
  tool: entry.tool,
113
146
  success: telemetrySuccess,
114
147
  duration_ms: entry.duration_ms,
115
148
  error_class: errorClass,
149
+ result_bytes: resultBytes,
150
+ retry: isTelemetryRetry,
116
151
  });
117
152
  if (!telemetrySuccess) {
118
153
  this.telemetry.recordDiagnostic({
@@ -122,6 +157,9 @@ export class EditmameiServer {
122
157
  (isPingDowngrade
123
158
  ? 'ps_ping did not reach Photoshop'
124
159
  : 'tool reported failure with no message'),
160
+ ...(this.lastPsLocale !== null ? { ps_locale: this.lastPsLocale } : {}),
161
+ ...(this.lastDocDepth !== null ? { doc_depth: this.lastDocDepth } : {}),
162
+ ...(this.lastDocMode !== null ? { doc_mode: this.lastDocMode } : {}),
125
163
  });
126
164
  }
127
165
  },
@@ -136,6 +174,21 @@ export class EditmameiServer {
136
174
  });
137
175
  const srv = this.server;
138
176
  this.sessionLog.setMcpClientGetter(() => srv.getClientVersion?.() ?? null);
177
+ let clientConnectedSent = false;
178
+ this.server.oninitialized = () => {
179
+ if (clientConnectedSent)
180
+ return;
181
+ clientConnectedSent = true;
182
+ const clientInfo = this.server.getClientVersion();
183
+ const caps = this.server.getClientCapabilities();
184
+ this.telemetry.recordClientConnected({
185
+ clientName: clientInfo?.name,
186
+ clientVersion: clientInfo?.version,
187
+ capSampling: caps?.sampling != null,
188
+ capElicitation: caps?.elicitation != null,
189
+ capRoots: caps?.roots != null,
190
+ });
191
+ };
139
192
  this.registerTools();
140
193
  this.setupHandlers();
141
194
  }
@@ -223,6 +276,7 @@ export class EditmameiServer {
223
276
  logger: this.logger,
224
277
  assertToolsClassified: () => this.assertToolsClassified(),
225
278
  classifyTool: (name) => this.classifyTool(name),
279
+ onModuleUpdate: (outcome) => this.telemetry.setModuleUpdate(outcome),
226
280
  });
227
281
  const proModule = this.moduleLifecycle.resolveProModule();
228
282
  this.kernel = new Kernel({
@@ -332,6 +386,11 @@ export class EditmameiServer {
332
386
  clearPendingRawDevelop();
333
387
  }
334
388
  }
389
+ else if (name === 'ps_develop_raw') {
390
+ const sc = result.structuredContent;
391
+ if (sc?.opened === true)
392
+ clearPendingRawDevelop();
393
+ }
335
394
  else if (name === 'ps_apply_camera_raw' || name === 'ps_close_document') {
336
395
  clearPendingRawDevelop();
337
396
  }
@@ -369,9 +428,14 @@ export class EditmameiServer {
369
428
  const connection = this.session.getConnection();
370
429
  let version = 'Unknown';
371
430
  let actionSetsCount = 0;
431
+ let actionSetsObserved = false;
372
432
  let openDocuments = [];
433
+ let psLocale = null;
434
+ let docDepth = null;
435
+ let docMode = null;
373
436
  const degraded = [];
374
437
  let versionIsLive = false;
438
+ let pingStateObserved = false;
375
439
  try {
376
440
  const detected = await connection.getVersion();
377
441
  if (detected)
@@ -381,6 +445,8 @@ export class EditmameiServer {
381
445
  }
382
446
  if (connection.getPhotoshopInfo() !== null && !(await connection.isCurrentlyRunning())) {
383
447
  this.lastPingReachedPs = false;
448
+ this.lastDocDepth = null;
449
+ this.lastDocMode = null;
384
450
  return {
385
451
  content: [
386
452
  {
@@ -406,6 +472,8 @@ export class EditmameiServer {
406
472
  if (!alive) {
407
473
  const reason = unsupportedHostReason();
408
474
  this.lastPingReachedPs = false;
475
+ this.lastDocDepth = null;
476
+ this.lastDocMode = null;
409
477
  return {
410
478
  content: [
411
479
  {
@@ -430,6 +498,8 @@ export class EditmameiServer {
430
498
  catch (err) {
431
499
  this.logger.warn(`pingState snippet failed: ${err instanceof Error ? err.message : String(err)}`);
432
500
  this.lastPingReachedPs = false;
501
+ this.lastDocDepth = null;
502
+ this.lastDocMode = null;
433
503
  return {
434
504
  content: [{ type: 'text', text: 'Photoshop did not respond' + update.note }],
435
505
  structuredContent: {
@@ -439,19 +509,36 @@ export class EditmameiServer {
439
509
  },
440
510
  };
441
511
  }
512
+ pingStateObserved = true;
442
513
  if (state.version) {
443
514
  version = state.version;
444
515
  versionIsLive = true;
445
516
  }
446
- if (typeof state.action_sets_count === 'number')
517
+ if (typeof state.action_sets_count === 'number') {
447
518
  actionSetsCount = state.action_sets_count;
519
+ actionSetsObserved = true;
520
+ }
448
521
  if (Array.isArray(state.open_documents))
449
522
  openDocuments = state.open_documents;
523
+ if (typeof state.locale === 'string')
524
+ psLocale = state.locale;
525
+ if (state.doc_depth === 8 || state.doc_depth === 16 || state.doc_depth === 32) {
526
+ docDepth = state.doc_depth;
527
+ }
528
+ if (state.doc_mode === 'rgb' ||
529
+ state.doc_mode === 'cmyk' ||
530
+ state.doc_mode === 'lab' ||
531
+ state.doc_mode === 'grayscale' ||
532
+ state.doc_mode === 'other') {
533
+ docMode = state.doc_mode;
534
+ }
450
535
  }
451
536
  let userTemplates = 0;
537
+ let templatesObserved = false;
452
538
  try {
453
539
  const templates = await listTemplates();
454
540
  userTemplates = templates.length;
541
+ templatesObserved = true;
455
542
  }
456
543
  catch (err) {
457
544
  this.logger.warn(`listTemplates failed: ${err instanceof Error ? err.message : String(err)}`);
@@ -464,6 +551,16 @@ export class EditmameiServer {
464
551
  this.psVersion = version;
465
552
  this.telemetry.onPsVersionResolved();
466
553
  }
554
+ if (psLocale !== null)
555
+ this.lastPsLocale = psLocale;
556
+ if (pingStateObserved) {
557
+ this.lastDocDepth = docDepth;
558
+ this.lastDocMode = docMode;
559
+ }
560
+ this.telemetry.setInstallAssets({
561
+ ...(templatesObserved ? { templates_saved: userTemplates } : {}),
562
+ ...(actionSetsObserved ? { action_sets: actionSetsCount } : {}),
563
+ });
467
564
  this.lastPingReachedPs = true;
468
565
  const degradedNote = degraded.length ? ` (degraded: ${degraded.join(', ')})` : '';
469
566
  return {
@@ -117,6 +117,7 @@ export const TOOL_GROUPS = {
117
117
  ps_apply_adjustment: 'adjust',
118
118
  ps_filter: 'filter',
119
119
  ps_apply_camera_raw: 'filter',
120
+ ps_develop_raw: 'filter',
120
121
  ps_retouch: 'retouch',
121
122
  ps_apply_brush_stroke: 'retouch',
122
123
  ps_create_layer: 'layers',
@@ -45,6 +45,7 @@ export const TOOL_TIERS = {
45
45
  ps_warp_layer_region: 'pro',
46
46
  ps_warp_layer_to: 'pro',
47
47
  ps_apply_camera_raw: 'pro',
48
+ ps_develop_raw: 'pro',
48
49
  ps_transform_canvas: 'community',
49
50
  ps_guides: 'community',
50
51
  ps_inspect: 'community',
@@ -1,8 +1,58 @@
1
+ import { realpathSync } from 'node:fs';
1
2
  import { EDITION } from './edition.js';
2
- export function resolveInstallChannel(env = process.env, edition = EDITION) {
3
+ function pathSegments(path) {
4
+ return path
5
+ .split(/[\\/]/)
6
+ .filter((s) => s.length > 0)
7
+ .map((s) => s.toLowerCase());
8
+ }
9
+ function defaultRealpath(p) {
10
+ try {
11
+ return realpathSync.native(p);
12
+ }
13
+ catch {
14
+ return p;
15
+ }
16
+ }
17
+ function safeRealpath(p, realpath) {
18
+ try {
19
+ return realpath(p);
20
+ }
21
+ catch {
22
+ return p;
23
+ }
24
+ }
25
+ function prefixMatches(execDirSegments, entryPrefixSegments) {
26
+ return (execDirSegments.length > 0 && execDirSegments.every((seg, i) => entryPrefixSegments[i] === seg));
27
+ }
28
+ export function resolveInstallChannel(env = process.env, edition = EDITION, argv1 = process.argv[1], execPath = process.execPath, realpath = defaultRealpath) {
3
29
  if (edition === 'dev')
4
30
  return 'dev';
5
31
  if (env.EDITMAMEI_INSTALL_CHANNEL === 'mcpb')
6
32
  return 'mcpb';
7
- return 'npm';
33
+ const trimmedArgv1 = argv1?.trim();
34
+ if (!trimmedArgv1)
35
+ return 'unknown';
36
+ const resolvedArgv1 = safeRealpath(trimmedArgv1, realpath);
37
+ const segments = pathSegments(resolvedArgv1);
38
+ if (segments.includes('_npx'))
39
+ return 'npx';
40
+ const nodeModulesIdx = segments.indexOf('node_modules');
41
+ if (nodeModulesIdx === -1)
42
+ return 'source';
43
+ const precedingSegment = nodeModulesIdx > 0 ? segments[nodeModulesIdx - 1] : undefined;
44
+ if (precedingSegment === 'lib' || precedingSegment === 'npm')
45
+ return 'npm_global';
46
+ const resolvedExecPath = execPath === undefined ? undefined : safeRealpath(execPath, realpath);
47
+ const resolvedExecDir = pathSegments(resolvedExecPath ?? '').slice(0, -1);
48
+ const resolvedEntryPrefix = segments.slice(0, nodeModulesIdx);
49
+ const rawExecDir = execPath === undefined ? [] : pathSegments(execPath).slice(0, -1);
50
+ const rawArgv1Segments = pathSegments(trimmedArgv1);
51
+ const rawNodeModulesIdx = rawArgv1Segments.indexOf('node_modules');
52
+ const rawEntryPrefix = rawNodeModulesIdx === -1 ? [] : rawArgv1Segments.slice(0, rawNodeModulesIdx);
53
+ const underExecDir = prefixMatches(resolvedExecDir, resolvedEntryPrefix) ||
54
+ prefixMatches(rawExecDir, rawEntryPrefix);
55
+ if (underExecDir)
56
+ return 'npm_global';
57
+ return 'npm_local';
8
58
  }
@@ -153,6 +153,7 @@ export class ModuleLifecycle {
153
153
  'Run `editmamei activate <key>` to restore Pro.');
154
154
  return;
155
155
  }
156
+ let outcome = null;
156
157
  try {
157
158
  const prov = await provisionModules(license.key, {
158
159
  force: reason === 'corrupt',
@@ -165,33 +166,44 @@ export class ModuleLifecycle {
165
166
  for (const m of prov.installed) {
166
167
  this.deps.logger.info(`Pro module updated to v${m.version}, restart to load.`);
167
168
  }
168
- return;
169
+ outcome = 'updated';
169
170
  }
170
- if (prov.notConfigured) {
171
+ else if (prov.notConfigured) {
171
172
  this.deps.logger.warn('Module delivery is not configured — staying Community.');
172
- return;
173
- }
174
- const abiTooNew = prov.errors.some((e) => e.code === 'abi_too_new');
175
- for (const e of prov.errors) {
176
- if (e.code === 'abi_too_new')
177
- continue;
178
- this.deps.logger.warn(`Could not re-provision the ${e.sku} module (staying Community): ${e.message}`);
179
173
  }
180
- if (prov.errors.length === 0 || abiTooNew) {
181
- if (reason === 'incompatible' || abiTooNew) {
182
- this.deps.logger.warn('The published Pro module does not yet support this host version — staying ' +
183
- 'Community. Update Editmamei when a compatible release ships; ' +
184
- '`editmamei report` files a diagnostic.');
174
+ else {
175
+ const abiTooNew = prov.errors.some((e) => e.code === 'abi_too_new');
176
+ for (const e of prov.errors) {
177
+ if (e.code === 'abi_too_new')
178
+ continue;
179
+ this.deps.logger.warn(`Could not re-provision the ${e.sku} module (staying Community): ${e.message}`);
185
180
  }
186
- else {
187
- this.deps.logger.warn('Re-provision installed nothing for the corrupt Pro module — staying ' +
188
- 'Community. Try `editmamei repair`; `editmamei report` if it persists.');
181
+ if (prov.errors.length === 0 || abiTooNew) {
182
+ if (reason === 'incompatible' || abiTooNew) {
183
+ this.deps.logger.warn('The published Pro module does not yet support this host version — staying ' +
184
+ 'Community. Update Editmamei when a compatible release ships; ' +
185
+ '`editmamei report` files a diagnostic.');
186
+ }
187
+ else {
188
+ this.deps.logger.warn('Re-provision installed nothing for the corrupt Pro module — staying ' +
189
+ 'Community. Try `editmamei repair`; `editmamei report` if it persists.');
190
+ }
189
191
  }
192
+ if (prov.errors.length > 0)
193
+ outcome = 'failed';
190
194
  }
191
195
  }
192
196
  catch (err) {
193
197
  this.deps.logger.warn(`Background Pro-module re-provision failed (staying Community): ` +
194
198
  `${err instanceof Error ? err.message : String(err)}`);
199
+ outcome = 'failed';
200
+ }
201
+ if (outcome !== null) {
202
+ try {
203
+ this.deps.onModuleUpdate?.(outcome);
204
+ }
205
+ catch {
206
+ }
195
207
  }
196
208
  }
197
209
  async ensureEntitledModuleFresh(delivery = {}) {
@@ -205,6 +217,7 @@ export class ModuleLifecycle {
205
217
  const license = readLicense();
206
218
  if (!license)
207
219
  return;
220
+ let outcome = null;
208
221
  try {
209
222
  const prov = await provisionModules(license.key, {
210
223
  config: delivery.config,
@@ -215,6 +228,12 @@ export class ModuleLifecycle {
215
228
  for (const m of prov.installed) {
216
229
  this.deps.logger.info(`Pro module updated to v${m.version} — restart to load.`);
217
230
  }
231
+ if (prov.installed.length > 0) {
232
+ outcome = 'updated';
233
+ }
234
+ else if (prov.errors.length > 0) {
235
+ outcome = 'failed';
236
+ }
218
237
  for (const e of prov.errors) {
219
238
  if (e.code === 'abi_too_new') {
220
239
  this.deps.logger.info(`A newer Pro module is published but needs a newer Editmamei than this ` +
@@ -228,6 +247,14 @@ export class ModuleLifecycle {
228
247
  catch (err) {
229
248
  this.deps.logger.warn(`Background Pro-module freshness check failed (staying on the installed version): ` +
230
249
  `${err instanceof Error ? err.message : String(err)}`);
250
+ outcome = 'failed';
251
+ }
252
+ if (outcome !== null) {
253
+ try {
254
+ this.deps.onModuleUpdate?.(outcome);
255
+ }
256
+ catch {
257
+ }
231
258
  }
232
259
  }
233
260
  }
Binary file
@@ -0,0 +1,149 @@
1
+ export const READ_ONLY_TOOLS = new Set([
2
+ 'ps_sequence',
3
+ 'ps_batch',
4
+ 'ps_ping',
5
+ 'ps_overview',
6
+ 'ps_list_capabilities',
7
+ 'ps_get_preview',
8
+ 'ps_get_selection_preview',
9
+ 'ps_get_histogram',
10
+ 'ps_inspect',
11
+ 'ps_read_scene',
12
+ 'ps_compare_regions',
13
+ 'ps_get_layer_bounds_diff',
14
+ 'ps_template_list',
15
+ 'ps_template_recall',
16
+ 'ps_list_actions',
17
+ 'ps_report_problem',
18
+ 'ps_detect',
19
+ 'ps_detect_landmarks',
20
+ 'ps_document',
21
+ 'ps_template_verify',
22
+ 'ps_resolve_placement',
23
+ 'ps_template_save',
24
+ 'ps_template_delete',
25
+ 'ps_template_create_evidence',
26
+ ]);
27
+ export const KEPT_WORK_TOOLS = new Set(['ps_export', 'ps_save_psd']);
28
+ export const MUTATING_TOOLS = new Set([
29
+ 'ps_play_action',
30
+ 'ps_execute_script',
31
+ 'ps_add_adjustment_layer',
32
+ 'ps_apply_adjustment',
33
+ 'ps_create_document',
34
+ 'ps_close_document',
35
+ 'ps_open_document',
36
+ 'ps_develop_raw',
37
+ 'ps_filter',
38
+ 'ps_group',
39
+ 'ps_clipping_mask',
40
+ 'ps_undo',
41
+ 'ps_redo',
42
+ 'ps_place_image',
43
+ 'ps_resize_image',
44
+ 'ps_crop_document',
45
+ 'ps_convert_image_mode',
46
+ 'ps_move_layer_to_position',
47
+ 'ps_convert_to_smart_object',
48
+ 'ps_rasterize_layer',
49
+ 'ps_set_layer',
50
+ 'ps_duplicate_layer',
51
+ 'ps_copy_to_new_layer',
52
+ 'ps_merge',
53
+ 'ps_bake_layer',
54
+ 'ps_add_layer_style',
55
+ 'ps_create_layer',
56
+ 'ps_delete_layer',
57
+ 'ps_fill_layer',
58
+ 'ps_add_fill_layer',
59
+ 'ps_select_layer',
60
+ 'ps_transform_layer',
61
+ 'ps_warp_layer',
62
+ 'ps_warp_layer_mesh',
63
+ 'ps_warp_layer_along',
64
+ 'ps_warp_layer_region',
65
+ 'ps_warp_layer_to',
66
+ 'ps_apply_camera_raw',
67
+ 'ps_transform_canvas',
68
+ 'ps_guides',
69
+ 'ps_retouch',
70
+ 'ps_apply_brush_stroke',
71
+ 'ps_edit_object',
72
+ 'ps_portrait_touchup',
73
+ 'ps_add_text_to_object',
74
+ 'ps_select_face_feature',
75
+ 'ps_stroke_face_contour',
76
+ 'ps_select_by_reference',
77
+ 'ps_select',
78
+ 'ps_select_subject',
79
+ 'ps_select_sky',
80
+ 'ps_select_subject_instance',
81
+ 'ps_select_object',
82
+ 'ps_replace_sky',
83
+ 'ps_modify_selection',
84
+ 'ps_selection_channel',
85
+ 'ps_layer_mask',
86
+ 'ps_path',
87
+ 'ps_vector_mask',
88
+ 'ps_apply_image',
89
+ 'ps_calculations',
90
+ 'ps_shape',
91
+ 'ps_template_apply',
92
+ 'ps_text',
93
+ ]);
94
+ export function mapClientName(name) {
95
+ if (name === undefined)
96
+ return 'other';
97
+ const n = name.toLowerCase();
98
+ if (n.includes('claude-code') || n.includes('claude_code') || n.includes('claudecode')) {
99
+ return 'claude_code';
100
+ }
101
+ if (n.includes('claude'))
102
+ return 'claude_desktop';
103
+ if (n.includes('cursor'))
104
+ return 'cursor';
105
+ if (n.includes('windsurf'))
106
+ return 'windsurf';
107
+ if (n.includes('vscode') || n.includes('visual studio') || n.includes('code-oss')) {
108
+ return 'vscode';
109
+ }
110
+ return 'other';
111
+ }
112
+ export function parseMajor(version) {
113
+ if (version === undefined)
114
+ return null;
115
+ const m = /^(\d+)/.exec(version.trim());
116
+ return m ? Number(m[1]) : null;
117
+ }
118
+ export function boundMajor(n, max) {
119
+ return n !== null && Number.isFinite(n) && n >= 0 && n <= max ? n : null;
120
+ }
121
+ export function nodeMajor() {
122
+ return parseMajor(process.versions.node);
123
+ }
124
+ export function archToken() {
125
+ if (process.arch === 'x64')
126
+ return 'x64';
127
+ if (process.arch === 'arm64')
128
+ return 'arm64';
129
+ return 'other';
130
+ }
131
+ export function osMajor(platform, release) {
132
+ if (platform === 'win32') {
133
+ const parts = release.split('.');
134
+ const major = Number(parts[0]);
135
+ if (!Number.isFinite(major))
136
+ return null;
137
+ const build = Number(parts[2]);
138
+ return Number.isFinite(build) && build >= 22000 ? 11 : major;
139
+ }
140
+ if (platform === 'darwin') {
141
+ const m = /^(\d+)/.exec(release);
142
+ if (!m)
143
+ return null;
144
+ const darwinMajor = Number(m[1]);
145
+ return darwinMajor <= 24 ? darwinMajor - 9 : darwinMajor + 1;
146
+ }
147
+ const m = /^(\d+)/.exec(release);
148
+ return m ? Number(m[1]) : null;
149
+ }