editmamei 1.6.0 → 1.7.1

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 (36) hide show
  1. package/README.md +11 -4
  2. package/dist/backends/gimp/backend.js +3 -0
  3. package/dist/backends/gimp/bridge/lib.py +766 -7
  4. package/dist/backends/gimp/bridge/ops.py +1693 -74
  5. package/dist/backends/gimp/session.js +3 -0
  6. package/dist/bin/editmamei-core-darwin-arm64 +0 -0
  7. package/dist/bin/editmamei-core-darwin-x64 +0 -0
  8. package/dist/bin/editmamei-core-win-x64.exe +0 -0
  9. package/dist/cli/help.js +2 -2
  10. package/dist/cli/install.js +2 -2
  11. package/dist/core/preview-privacy.js +54 -0
  12. package/dist/core/server.js +4 -0
  13. package/dist/core/tool-activity.js +8 -0
  14. package/dist/core/tool-groups.js +8 -0
  15. package/dist/core/tool-tiers.js +8 -0
  16. package/dist/install-channel.js +2 -0
  17. package/dist/modules/gimp/index.js +8 -0
  18. package/dist/skills/editmamei-skill.zip +0 -0
  19. package/dist/telemetry/client.js +23 -16
  20. package/dist/telemetry/outbox.js +248 -28
  21. package/dist/tools/gimp-adjustment-tools.js +6 -3
  22. package/dist/tools/gimp-checkpoint-tools.js +541 -0
  23. package/dist/tools/gimp-compose-tools.js +399 -0
  24. package/dist/tools/gimp-core-tools.js +20 -6
  25. package/dist/tools/gimp-effect-tools.js +267 -0
  26. package/dist/tools/gimp-filter-tools.js +2 -1
  27. package/dist/tools/gimp-geometry-tools.js +46 -14
  28. package/dist/tools/gimp-inspect-tools.js +108 -21
  29. package/dist/tools/gimp-layer-tools.js +385 -0
  30. package/dist/tools/gimp-shared.js +7 -0
  31. package/dist/tools/gimp-verify-tools.js +38 -3
  32. package/dist/update/check.js +2 -0
  33. package/dist/utils/operation-timeouts.js +9 -1
  34. package/dist/utils/validate.js +11 -0
  35. package/dist/version.js +1 -1
  36. package/package.json +3 -3
@@ -122,6 +122,9 @@ export class GimpSession {
122
122
  get lastStartOrigin() {
123
123
  return this.startOrigin;
124
124
  }
125
+ get generation() {
126
+ return this.deadGeneration;
127
+ }
125
128
  latestPreviewPath() {
126
129
  return join(this.rootDir, 'latest-preview.jpg');
127
130
  }
Binary file
Binary file
package/dist/cli/help.js CHANGED
@@ -27,8 +27,8 @@ Install options:
27
27
 
28
28
  --skip-skill Skip copying the editmamei skill bundle to your Downloads folder.
29
29
  By default, install drops editmamei-skill.zip in Downloads with
30
- instructions for uploading it to claude.ai (Settings > Customize >
31
- Skills). Pass this flag for headless / CI installs where the skill
30
+ instructions for uploading it to claude.ai (Settings > Skills).
31
+ Pass this flag for headless / CI installs where the skill
32
32
  upload step doesn't apply.
33
33
 
34
34
  Config examples:
@@ -61,8 +61,8 @@ export async function runInstall(opts = {}) {
61
61
  }
62
62
  if (skillResult?.status === 'copied') {
63
63
  out(` 2. Upload the editmamei skill to your Claude account:\n`);
64
- out(` - Open https://claude.ai/settings (Customize > Skills)\n`);
65
- out(` - Click "Upload skill"\n`);
64
+ out(` - Open https://claude.ai/settings (Settings > Skills)\n`);
65
+ out(` - Click Add, then "Upload skill"\n`);
66
66
  out(` - Choose the file at ${skillResult.destPath}\n`);
67
67
  out(` The skill auto-loads in any photo-editing conversation. It's a one-time upload.\n`);
68
68
  out(` 3. Then ask the AI: "Is Photoshop connected?" — it will call ps_ping and return your Photoshop version.\n`);
@@ -0,0 +1,54 @@
1
+ function isImageBlock(value) {
2
+ if (typeof value !== 'object' || value === null)
3
+ return false;
4
+ const block = value;
5
+ if (block.type === 'image') {
6
+ return typeof block.data === 'string' && typeof block.mimeType === 'string';
7
+ }
8
+ return (block.type === 'resource' &&
9
+ typeof block.resource?.blob === 'string' &&
10
+ typeof block.resource.mimeType === 'string' &&
11
+ block.resource.mimeType.startsWith('image/'));
12
+ }
13
+ function strip(value, counter) {
14
+ if (Array.isArray(value)) {
15
+ const out = [];
16
+ for (const item of value) {
17
+ if (isImageBlock(item))
18
+ counter.n++;
19
+ else
20
+ out.push(strip(item, counter));
21
+ }
22
+ return out;
23
+ }
24
+ if (typeof value === 'object' && value !== null) {
25
+ const out = {};
26
+ for (const [k, v] of Object.entries(value))
27
+ out[k] = strip(v, counter);
28
+ return out;
29
+ }
30
+ return value;
31
+ }
32
+ export function hasImageContent(result) {
33
+ const counter = { n: 0 };
34
+ strip(result, counter);
35
+ return counter.n > 0;
36
+ }
37
+ export function withholdImages(result) {
38
+ const counter = { n: 0 };
39
+ const stripped = strip(result, counter);
40
+ if (counter.n === 0)
41
+ return result;
42
+ const what = counter.n === 1 ? 'An image was' : `${counter.n} images were`;
43
+ return {
44
+ ...stripped,
45
+ content: [
46
+ ...(stripped.content ?? []),
47
+ {
48
+ type: 'text',
49
+ text: `${what} withheld because the user's privacy.send_previews_to_llm setting is off. ` +
50
+ 'Work from the text and numbers; ask the user if you need to see the image.',
51
+ },
52
+ ],
53
+ };
54
+ }
@@ -6,6 +6,7 @@ import { ToolRegistry } from './tool-registry.js';
6
6
  import { tierOf } from './tool-tiers.js';
7
7
  import { groupOf, GROUPS } from './tool-groups.js';
8
8
  import { CAMERA_RAW_TOOL, RAW_DEVELOP_TOOL } from './tool-activity.js';
9
+ import { hasImageContent, withholdImages } from './preview-privacy.js';
9
10
  import { EDITION } from '../edition.js';
10
11
  import { VERSION } from '../version.js';
11
12
  import { Session } from './session.js';
@@ -90,6 +91,7 @@ export class EditmameiServer {
90
91
  updateInfo = null;
91
92
  updateCheck = null;
92
93
  updateNoticeShown = false;
94
+ previewsAllowed = () => loadSettings().settings.privacy.send_previews_to_llm;
93
95
  snippetClient = new GoSnippetClient();
94
96
  refreshLicenseOnPing = createPingLicenseRefresher();
95
97
  moduleUpdateFailed = false;
@@ -436,6 +438,8 @@ export class EditmameiServer {
436
438
  const result = await this.toolRegistry.execute(name, args);
437
439
  this.session.updateActivity();
438
440
  this.trackRawDevelopState(name, result);
441
+ if (hasImageContent(result) && !this.previewsAllowed())
442
+ return withholdImages(result);
439
443
  return result;
440
444
  }
441
445
  catch (error) {
@@ -104,11 +104,19 @@ export const MUTATING_TOOLS = new Set([
104
104
  'gimp_open_document',
105
105
  'gimp_close_document',
106
106
  'gimp_add_adjustment',
107
+ 'gimp_add_effect',
107
108
  'gimp_filter',
108
109
  'gimp_crop_document',
109
110
  'gimp_resize_image',
110
111
  'gimp_transform_canvas',
111
112
  'gimp_create_mask',
113
+ 'gimp_checkpoint',
114
+ 'gimp_layer',
115
+ 'gimp_bake',
116
+ 'gimp_create_document',
117
+ 'gimp_place_image',
118
+ 'gimp_canvas',
119
+ 'gimp_convert_image_mode',
112
120
  ]);
113
121
  export const RAW_DEVELOP_TOOL = 'ps_develop_raw';
114
122
  export const CAMERA_RAW_TOOL = 'ps_apply_camera_raw';
@@ -186,7 +186,15 @@ export const TOOL_GROUPS = {
186
186
  gimp_transform_canvas: 'document',
187
187
  gimp_add_adjustment: 'adjust',
188
188
  gimp_filter: 'filter',
189
+ gimp_add_effect: 'filter',
189
190
  gimp_create_mask: 'masks',
191
+ gimp_checkpoint: 'document',
192
+ gimp_layer: 'layers',
193
+ gimp_bake: 'layers',
194
+ gimp_create_document: 'document',
195
+ gimp_place_image: 'layers',
196
+ gimp_canvas: 'document',
197
+ gimp_convert_image_mode: 'document',
190
198
  };
191
199
  export function groupOf(toolName) {
192
200
  const group = TOOL_GROUPS[toolName];
@@ -98,6 +98,7 @@ export const TOOL_TIERS = {
98
98
  gimp_inspect: 'community',
99
99
  gimp_add_adjustment: 'community',
100
100
  gimp_filter: 'community',
101
+ gimp_add_effect: 'community',
101
102
  gimp_crop_document: 'community',
102
103
  gimp_resize_image: 'community',
103
104
  gimp_transform_canvas: 'community',
@@ -105,6 +106,13 @@ export const TOOL_TIERS = {
105
106
  gimp_get_preview: 'community',
106
107
  gimp_get_histogram: 'community',
107
108
  gimp_compare: 'community',
109
+ gimp_checkpoint: 'community',
110
+ gimp_layer: 'community',
111
+ gimp_bake: 'community',
112
+ gimp_create_document: 'community',
113
+ gimp_place_image: 'community',
114
+ gimp_canvas: 'community',
115
+ gimp_convert_image_mode: 'community',
108
116
  };
109
117
  export function tierOf(toolName) {
110
118
  const tier = TOOL_TIERS[toolName];
@@ -30,6 +30,8 @@ export function resolveInstallChannel(env = process.env, edition = EDITION, argv
30
30
  return 'dev';
31
31
  if (env.EDITMAMEI_INSTALL_CHANNEL === 'mcpb')
32
32
  return 'mcpb';
33
+ if (env.EDITMAMEI_INSTALL_CHANNEL === 'plugin')
34
+ return 'plugin';
33
35
  const trimmedArgv1 = argv1?.trim();
34
36
  if (!trimmedArgv1)
35
37
  return 'unknown';
@@ -5,19 +5,27 @@ import { createGimpCoreTools } from '../../tools/gimp-core-tools.js';
5
5
  import { createGimpDocumentTools } from '../../tools/gimp-document-tools.js';
6
6
  import { createGimpInspectTools } from '../../tools/gimp-inspect-tools.js';
7
7
  import { createGimpAdjustmentTools } from '../../tools/gimp-adjustment-tools.js';
8
+ import { createGimpEffectTools } from '../../tools/gimp-effect-tools.js';
8
9
  import { createGimpFilterTools } from '../../tools/gimp-filter-tools.js';
9
10
  import { createGimpGeometryTools } from '../../tools/gimp-geometry-tools.js';
10
11
  import { createGimpMaskTools } from '../../tools/gimp-mask-tools.js';
11
12
  import { createGimpVerifyTools } from '../../tools/gimp-verify-tools.js';
13
+ import { createGimpCheckpointTools } from '../../tools/gimp-checkpoint-tools.js';
14
+ import { createGimpLayerTools } from '../../tools/gimp-layer-tools.js';
15
+ import { createGimpComposeTools } from '../../tools/gimp-compose-tools.js';
12
16
  export const gimpFactories = [
13
17
  createGimpCoreTools,
14
18
  createGimpDocumentTools,
15
19
  createGimpInspectTools,
16
20
  createGimpAdjustmentTools,
21
+ createGimpEffectTools,
17
22
  createGimpFilterTools,
18
23
  createGimpGeometryTools,
19
24
  createGimpMaskTools,
20
25
  createGimpVerifyTools,
26
+ createGimpCheckpointTools,
27
+ createGimpLayerTools,
28
+ createGimpComposeTools,
21
29
  ];
22
30
  export const gimpModule = {
23
31
  manifest: { id: 'gimp', name: 'Editmamei GIMP Tools', abi: KERNEL_ABI },
Binary file
@@ -7,7 +7,7 @@ import { READ_ONLY_TOOLS, KEPT_WORK_TOOLS, nodeMajor, archToken, osMajor, boundM
7
7
  import { buildClientConnected, buildDiagnosticEvent, buildModuleStatus, buildSessionStart, buildSessionSummary, buildUsageEvent, dayBucket, normalizeDayBucket, isContentSafe, PS_VERSION_UNKNOWN, } from './events.js';
8
8
  import { sanitizeMessage, sanitizeSnippet, sanitizeStderrTail } from './sanitize.js';
9
9
  import { httpTransport, resolveEndpoint } from './transport.js';
10
- import { appendOutboxSync, clearOutbox, clearSessionState, readOutboxWithDiscards, readSessionState, rewriteOutbox, writeSessionStateSync, } from './outbox.js';
10
+ import { appendOutboxSync, claimOrphanedSessionStates, claimOutboxForDrain, clearSessionState, writeSessionStateSync, } from './outbox.js';
11
11
  const MAX_BATCH_SIZE = 100;
12
12
  const MAX_QUEUE_SIZE = 500;
13
13
  const DEFAULT_FLUSH_INTERVAL_MS = 5 * 60_000;
@@ -50,6 +50,8 @@ export class TelemetryClient {
50
50
  moduleUpdate = 'none';
51
51
  installAssets = {};
52
52
  lastSessionPersistMs = 0;
53
+ sessionStatePersisted = false;
54
+ startupFlushStarted = false;
53
55
  startDayBucket = null;
54
56
  constructor(opts) {
55
57
  this.settings = opts.settings;
@@ -59,7 +61,11 @@ export class TelemetryClient {
59
61
  this.flushIntervalMs = opts.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS;
60
62
  this.maxBatchSize = opts.maxBatchSize ?? MAX_BATCH_SIZE;
61
63
  this.active = opts.active ?? (EDITION !== 'dev' && !isTestEnv());
62
- this.outboxOpts = opts.outboxDir ? { dir: opts.outboxDir } : {};
64
+ this.outboxOpts = {
65
+ ...(opts.outboxDir ? { dir: opts.outboxDir } : {}),
66
+ ...(opts.outboxPid !== undefined ? { pid: opts.outboxPid } : {}),
67
+ ...(opts.isPidAlive ? { isPidAlive: opts.isPidAlive } : {}),
68
+ };
63
69
  this.getModuleStatus = opts.getModuleStatus ?? (() => null);
64
70
  this.dims = {
65
71
  install_id: this.settings.telemetry.install_id,
@@ -200,6 +206,7 @@ export class TelemetryClient {
200
206
  ...this.summaryFields(),
201
207
  };
202
208
  writeSessionStateSync(state, this.outboxOpts);
209
+ this.sessionStatePersisted = true;
203
210
  }
204
211
  onPsVersionResolved() {
205
212
  if (!this.active || !this.settings.telemetry.usage || this.toolCallCount === 0)
@@ -297,22 +304,27 @@ export class TelemetryClient {
297
304
  clearSessionState(this.outboxOpts);
298
305
  }
299
306
  async flushOutboxOnStartup() {
300
- if (!this.active)
307
+ if (!this.active || this.startupFlushStarted)
301
308
  return;
309
+ this.startupFlushStarted = true;
302
310
  try {
303
- const stale = readSessionState(this.outboxOpts);
304
- if (stale && this.settings.telemetry.usage && stale.tool_call_count > 0) {
305
- appendOutboxSync([summaryFromState(stale)], this.outboxOpts);
311
+ const orphans = claimOrphanedSessionStates(this.outboxOpts, {
312
+ includeOwn: !this.sessionStatePersisted,
313
+ });
314
+ if (this.settings.telemetry.usage) {
315
+ const summaries = orphans.states.filter((s) => s.tool_call_count > 0).map(summaryFromState);
316
+ this.droppedOutbox += appendOutboxSync(summaries, this.outboxOpts);
306
317
  }
307
- clearSessionState(this.outboxOpts);
308
- const { events: pending, discarded } = readOutboxWithDiscards(this.outboxOpts);
318
+ orphans.release();
319
+ const claimed = claimOutboxForDrain(this.outboxOpts);
320
+ const { events: pending, discarded } = claimed;
309
321
  this.droppedOutbox += discarded;
310
322
  if (pending.length === 0) {
311
- clearOutbox(this.outboxOpts);
323
+ claimed.release([]);
312
324
  return;
313
325
  }
314
326
  if (!this.settings.telemetry.usage) {
315
- clearOutbox(this.outboxOpts);
327
+ claimed.release([]);
316
328
  return;
317
329
  }
318
330
  const sendable = pending.filter(isContentSafe);
@@ -330,12 +342,7 @@ export class TelemetryClient {
330
342
  }
331
343
  settled = i + batch.length;
332
344
  }
333
- if (settled >= sendable.length) {
334
- clearOutbox(this.outboxOpts);
335
- }
336
- else {
337
- rewriteOutbox(sendable.slice(settled), this.outboxOpts);
338
- }
345
+ this.droppedOutbox += claimed.release(sendable.slice(settled));
339
346
  }
340
347
  catch (err) {
341
348
  this.logger.debug(`startup outbox flush error: ${errMsg(err)}`);
@@ -1,21 +1,86 @@
1
1
  import { homedir } from 'node:os';
2
2
  import { join, dirname } from 'node:path';
3
- import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, appendFileSync, statSync, rmSync, } from 'node:fs';
3
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, renameSync, appendFileSync, statSync, rmSync, } from 'node:fs';
4
4
  import { Logger } from '../utils/logger.js';
5
5
  const logger = new Logger('TelemetryOutbox');
6
6
  const DIRNAME = '.editmamei';
7
7
  const OUTBOX_FILENAME = 'telemetry-outbox.ndjson';
8
- const SESSION_STATE_FILENAME = 'telemetry-session.json';
8
+ const LEGACY_SESSION_STATE_FILENAME = 'telemetry-session.json';
9
+ const SESSION_STATE_RE = /^telemetry-session\.(\d+)\.json$/;
10
+ const RECOVER_RE = /^\.session-recover\.(\d+)\.(telemetry-session(?:\.\d+)?\.json)$/;
11
+ const CLAIM_RE = /^telemetry-outbox\.(?:draining|compacting)\.(\d+)\.(\d+)\.ndjson$/;
9
12
  export const MAX_OUTBOX_EVENTS = 5_000;
10
13
  export const MAX_OUTBOX_BYTES = 2_000_000;
14
+ export function isPidAlive(pid) {
15
+ try {
16
+ process.kill(pid, 0);
17
+ return true;
18
+ }
19
+ catch (err) {
20
+ return err.code === 'EPERM';
21
+ }
22
+ }
11
23
  function baseDir(opts) {
12
24
  return opts.dir ?? join(homedir(), DIRNAME);
13
25
  }
14
26
  export function outboxPath(opts = {}) {
15
27
  return join(baseDir(opts), OUTBOX_FILENAME);
16
28
  }
29
+ function ownPid(opts) {
30
+ return opts.pid ?? process.pid;
31
+ }
17
32
  export function sessionStatePath(opts = {}) {
18
- return join(baseDir(opts), SESSION_STATE_FILENAME);
33
+ return join(baseDir(opts), `telemetry-session.${ownPid(opts)}.json`);
34
+ }
35
+ function listDir(dir) {
36
+ try {
37
+ return readdirSync(dir);
38
+ }
39
+ catch {
40
+ return [];
41
+ }
42
+ }
43
+ function claim(from, to) {
44
+ try {
45
+ renameSync(from, to);
46
+ return to;
47
+ }
48
+ catch {
49
+ return null;
50
+ }
51
+ }
52
+ function freeClaimName(dir, kind, pid) {
53
+ for (let seq = 0;; seq++) {
54
+ const path = join(dir, `telemetry-outbox.${kind}.${pid}.${seq}.ndjson`);
55
+ if (!existsSync(path))
56
+ return path;
57
+ }
58
+ }
59
+ function readClaim(path) {
60
+ const buf = readFileSync(path);
61
+ const bytes = buf.lastIndexOf(0x0a) + 1;
62
+ return { ...parseOutbox(buf.subarray(0, bytes).toString('utf8')), bytes };
63
+ }
64
+ function readClaimTail(path, bytes) {
65
+ const buf = readFileSync(path);
66
+ if (buf.length <= bytes)
67
+ return { events: [], discarded: 0 };
68
+ return parseOutbox(buf.subarray(bytes).toString('utf8'));
69
+ }
70
+ function removeClaim(path, what) {
71
+ try {
72
+ rmSync(path, { force: true });
73
+ return;
74
+ }
75
+ catch (err) {
76
+ logger.debug(`${what} delete failed: ${errMsg(err)}`);
77
+ }
78
+ try {
79
+ writeFileSync(path, '');
80
+ }
81
+ catch (err) {
82
+ logger.debug(`${what} truncate failed: ${errMsg(err)}`);
83
+ }
19
84
  }
20
85
  function ensureDir(path) {
21
86
  mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
@@ -44,31 +109,100 @@ export function readOutboxWithDiscards(opts = {}) {
44
109
  if (!existsSync(path))
45
110
  return { events: [], discarded: 0 };
46
111
  try {
47
- const raw = readFileSync(path, 'utf8');
48
- const events = [];
49
- let discarded = 0;
50
- for (const line of raw.split('\n')) {
51
- const trimmed = line.trim();
52
- if (trimmed.length === 0)
53
- continue;
54
- try {
55
- events.push(JSON.parse(trimmed));
56
- }
57
- catch {
58
- discarded += 1;
59
- }
60
- }
61
- if (events.length > MAX_OUTBOX_EVENTS) {
62
- const over = events.length - MAX_OUTBOX_EVENTS;
63
- return { events: events.slice(over), discarded: discarded + over };
64
- }
65
- return { events, discarded };
112
+ return boundOutbox(parseOutbox(readFileSync(path, 'utf8')));
66
113
  }
67
114
  catch (err) {
68
115
  logger.debug(`outbox read failed: ${errMsg(err)}`);
69
116
  return { events: [], discarded: 0 };
70
117
  }
71
118
  }
119
+ function parseOutbox(raw) {
120
+ const events = [];
121
+ let discarded = 0;
122
+ for (const line of raw.split('\n')) {
123
+ const trimmed = line.trim();
124
+ if (trimmed.length === 0)
125
+ continue;
126
+ try {
127
+ events.push(JSON.parse(trimmed));
128
+ }
129
+ catch {
130
+ discarded += 1;
131
+ }
132
+ }
133
+ return { events, discarded };
134
+ }
135
+ function boundOutbox({ events, discarded }) {
136
+ if (events.length <= MAX_OUTBOX_EVENTS)
137
+ return { events, discarded };
138
+ const over = events.length - MAX_OUTBOX_EVENTS;
139
+ return { events: events.slice(over), discarded: discarded + over };
140
+ }
141
+ export function claimOutboxForDrain(opts = {}) {
142
+ const dir = baseDir(opts);
143
+ const me = ownPid(opts);
144
+ const alive = opts.isPidAlive ?? isPidAlive;
145
+ const claimed = [];
146
+ const nextName = () => freeClaimName(dir, 'draining', me);
147
+ const abandoned = listDir(dir)
148
+ .flatMap((name) => {
149
+ const m = CLAIM_RE.exec(name);
150
+ return m ? [{ name, pid: Number(m[1]), seq: Number(m[2]) }] : [];
151
+ })
152
+ .filter((c) => c.pid === me || !alive(c.pid))
153
+ .sort((a, b) => a.pid - b.pid || a.seq - b.seq || a.name.localeCompare(b.name));
154
+ for (const c of abandoned) {
155
+ const from = join(dir, c.name);
156
+ const got = c.pid === me ? from : claim(from, nextName());
157
+ if (got)
158
+ claimed.push(got);
159
+ }
160
+ const live = claim(outboxPath(opts), nextName());
161
+ if (live)
162
+ claimed.push(live);
163
+ const consumed = [];
164
+ let read = { events: [], discarded: 0 };
165
+ for (const path of claimed) {
166
+ try {
167
+ const part = readClaim(path);
168
+ read = {
169
+ events: read.events.concat(part.events),
170
+ discarded: read.discarded + part.discarded,
171
+ };
172
+ consumed.push({ path, bytes: part.bytes });
173
+ }
174
+ catch (err) {
175
+ logger.debug(`claimed outbox read failed: ${errMsg(err)}`);
176
+ }
177
+ }
178
+ const { events, discarded } = boundOutbox(read);
179
+ let released = false;
180
+ return {
181
+ events,
182
+ discarded,
183
+ release(undelivered) {
184
+ if (released)
185
+ return 0;
186
+ released = true;
187
+ const late = [];
188
+ let lost = 0;
189
+ for (const { path, bytes } of consumed) {
190
+ try {
191
+ const tail = readClaimTail(path, bytes);
192
+ late.push(...tail.events);
193
+ lost += tail.discarded;
194
+ }
195
+ catch (err) {
196
+ logger.debug(`claimed outbox re-read failed: ${errMsg(err)}`);
197
+ }
198
+ }
199
+ lost += appendOutboxSync(undelivered.concat(late), opts);
200
+ for (const { path } of consumed)
201
+ removeClaim(path, 'claimed outbox');
202
+ return lost;
203
+ },
204
+ };
205
+ }
72
206
  export function readOutbox(opts = {}) {
73
207
  return readOutboxWithDiscards(opts).events;
74
208
  }
@@ -88,7 +222,7 @@ export function rewriteOutbox(events, opts = {}) {
88
222
  const path = outboxPath(opts);
89
223
  try {
90
224
  ensureDir(path);
91
- const tmp = join(dirname(path), `.outbox.${process.pid}.tmp`);
225
+ const tmp = join(dirname(path), `.outbox.${ownPid(opts)}.tmp`);
92
226
  writeFileSync(tmp, events.map((e) => JSON.stringify(e)).join('\n') + '\n', {
93
227
  encoding: 'utf8',
94
228
  mode: 0o600,
@@ -100,7 +234,18 @@ export function rewriteOutbox(events, opts = {}) {
100
234
  }
101
235
  }
102
236
  function compactOutbox(opts = {}) {
103
- const { events, discarded } = readOutboxWithDiscards(opts);
237
+ const path = claim(outboxPath(opts), freeClaimName(baseDir(opts), 'compacting', ownPid(opts)));
238
+ if (!path)
239
+ return 0;
240
+ let read;
241
+ try {
242
+ read = readClaim(path);
243
+ }
244
+ catch (err) {
245
+ logger.debug(`compaction read failed, leaving the claim for recovery: ${errMsg(err)}`);
246
+ return 0;
247
+ }
248
+ const { events, discarded } = boundOutbox(read);
104
249
  const target = MAX_OUTBOX_BYTES / 2;
105
250
  let bytes = 0;
106
251
  let firstKept = Math.max(events.length - 1, 0);
@@ -111,14 +256,34 @@ function compactOutbox(opts = {}) {
111
256
  firstKept = i;
112
257
  }
113
258
  const kept = events.slice(firstKept);
114
- rewriteOutbox(kept, opts);
115
- return discarded + (events.length - kept.length);
259
+ let late = { events: [], discarded: 0 };
260
+ try {
261
+ late = readClaimTail(path, read.bytes);
262
+ }
263
+ catch (err) {
264
+ logger.debug(`compaction re-read failed: ${errMsg(err)}`);
265
+ }
266
+ const back = kept.concat(late.events);
267
+ if (back.length > 0) {
268
+ try {
269
+ appendFileSync(outboxPath(opts), back.map((e) => JSON.stringify(e)).join('\n') + '\n', {
270
+ encoding: 'utf8',
271
+ mode: 0o600,
272
+ });
273
+ }
274
+ catch (err) {
275
+ logger.debug(`compaction write-back failed, leaving the claim for recovery: ${errMsg(err)}`);
276
+ return 0;
277
+ }
278
+ }
279
+ removeClaim(path, 'compaction claim');
280
+ return discarded + (events.length - kept.length) + late.discarded;
116
281
  }
117
282
  export function writeSessionStateSync(state, opts = {}) {
118
283
  const path = sessionStatePath(opts);
119
284
  try {
120
285
  ensureDir(path);
121
- const tmp = join(dirname(path), `.session.${process.pid}.tmp`);
286
+ const tmp = join(dirname(path), `.session.${ownPid(opts)}.tmp`);
122
287
  writeFileSync(tmp, JSON.stringify(state), { encoding: 'utf8', mode: 0o600 });
123
288
  renameSync(tmp, path);
124
289
  }
@@ -127,7 +292,62 @@ export function writeSessionStateSync(state, opts = {}) {
127
292
  }
128
293
  }
129
294
  export function readSessionState(opts = {}) {
130
- const path = sessionStatePath(opts);
295
+ return readSessionStateAt(sessionStatePath(opts));
296
+ }
297
+ export function claimOrphanedSessionStates(opts = {}, { includeOwn }) {
298
+ const dir = baseDir(opts);
299
+ const me = ownPid(opts);
300
+ const alive = opts.isPidAlive ?? isPidAlive;
301
+ const takeable = (pid) => (pid === me ? includeOwn : !alive(pid));
302
+ const claimed = [];
303
+ for (const name of listDir(dir)) {
304
+ let original;
305
+ const r = RECOVER_RE.exec(name);
306
+ if (r) {
307
+ const pid = Number(r[1]);
308
+ if (!takeable(pid))
309
+ continue;
310
+ if (pid === me) {
311
+ claimed.push(join(dir, name));
312
+ continue;
313
+ }
314
+ original = r[2];
315
+ }
316
+ else if (name === LEGACY_SESSION_STATE_FILENAME) {
317
+ original = name;
318
+ }
319
+ else {
320
+ const m = SESSION_STATE_RE.exec(name);
321
+ if (!m || !takeable(Number(m[1])))
322
+ continue;
323
+ original = name;
324
+ }
325
+ const target = join(dir, `.session-recover.${me}.${original}`);
326
+ if (existsSync(target))
327
+ continue;
328
+ const got = claim(join(dir, name), target);
329
+ if (got)
330
+ claimed.push(got);
331
+ }
332
+ const states = [];
333
+ for (const path of claimed) {
334
+ const state = readSessionStateAt(path);
335
+ if (state)
336
+ states.push(state);
337
+ }
338
+ let released = false;
339
+ return {
340
+ states,
341
+ release() {
342
+ if (released)
343
+ return;
344
+ released = true;
345
+ for (const path of claimed)
346
+ removeClaim(path, 'session-state recover');
347
+ },
348
+ };
349
+ }
350
+ function readSessionStateAt(path) {
131
351
  if (!existsSync(path))
132
352
  return null;
133
353
  try {