@attocash/cli 0.1.1 → 0.2.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
@@ -689,6 +689,45 @@ mnemonic is lost from the password store, recover it from your private backup
689
689
  using `atto wallet import` in an uninitialized profile. Preserve the old public
690
690
  state and pending-send records for reconciliation before resuming payments.
691
691
 
692
+ ## Background receiving and work preparation
693
+
694
+ `atto wallet receive` keeps automatic receiving in the current terminal. To
695
+ keep receiving after the terminal exits, use:
696
+
697
+ ```sh
698
+ atto wallet receive --background
699
+ atto wallet receive status
700
+ atto wallet receive stop
701
+ ```
702
+
703
+ The background receiver uses the same profile selected by `--data-dir`, the OS
704
+ password store, active addresses, minimum receive amount, and automatic
705
+ receiving setting. It does not install a service, does not start after reboot,
706
+ and does not grant MCP spending access. `wallet status` reports its separate
707
+ state and latest operational error. Stop it before resetting a profile.
708
+
709
+ All three commands accept `--data-dir <directory>` and `--json`. Starting or
710
+ stopping repeatedly is safe. Start acknowledges local initialization; it does
711
+ not promise that the node or password store is reachable. The detached process
712
+ inherits your login environment and needs access to the OS password store. On
713
+ Linux, keep the Secret Service session available; on macOS and Windows, allow
714
+ the CLI to access its credential. Check `wallet receive status` for retry errors.
715
+ No recovery phrase is stored in the public profile or passed on the command line.
716
+
717
+ Stop prevents new receives and reports `stopping` until the current operation
718
+ finishes. A crashed receiver reports `stopped`; start it explicitly again after
719
+ a crash, reboot, or restoring a backup. Foreground and MCP receivers may coexist,
720
+ but stopping this background receiver does not stop those separate sessions.
721
+
722
+ After a completed send, receive, representative change, or approved pool step,
723
+ the CLI may finish public proof-of-work preparation in a short detached process.
724
+ This contains only public account heads and worker settings, runs for at most a
725
+ minute, and caches usable work for a later transaction. A changed account head
726
+ or an immediate transaction can still require fresh work.
727
+ The worker exits when its queue is drained, keeps at most two speculative
728
+ requests active, and leaves failed or unfinished jobs eligible for a later CLI
729
+ invocation. Reset cancels queued preparation before clearing the profile.
730
+
692
731
  ## Update notices
693
732
 
694
733
  Interactive CLI commands can show a notice on stderr when a newer stable CLI
@@ -736,13 +775,13 @@ git clone https://github.com/attocash/integrations.git
736
775
  cd integrations
737
776
  npm ci
738
777
  npm run pack
739
- npm install --global ./attocash-cli-0.1.1.tgz
778
+ npm install --global ./attocash-cli-0.0.0.tgz
740
779
  ```
741
780
 
742
781
  To test MCP with the same local CLI build, install both artifacts together:
743
782
 
744
783
  ```sh
745
- npm install --global ./attocash-cli-0.1.1.tgz ./attocash-mcp-0.1.1.tgz
784
+ npm install --global ./attocash-cli-0.0.0.tgz ./attocash-mcp-0.0.0.tgz
746
785
  ```
747
786
 
748
787
  For development and testing, see the [contributor guide](https://github.com/attocash/integrations/blob/main/docs/contributing.md).
@@ -4,6 +4,7 @@ import type { SendRetry } from '../network/retry.js';
4
4
  import { type SecretStore } from '../storage/secrets.js';
5
5
  import { StateStore } from '../storage/state.js';
6
6
  import { SpendLedger, type McpAccess } from '../spending/ledger.js';
7
+ import { type WorkExecution } from '../wallet/work.js';
7
8
  import { type ReceiveProgress } from '../wallet/auto-receive.js';
8
9
  import type { AccountPool, SpendingPolicy, WalletIdentity } from '../wallet/types.js';
9
10
  import { MarketData } from '../pricing/market.js';
@@ -16,6 +17,10 @@ export declare class AttoApplication {
16
17
  private readonly labels;
17
18
  private readonly auto;
18
19
  private readonly work;
20
+ private readonly background;
21
+ private readonly receivingAllowed;
22
+ private preparation?;
23
+ private readonly preparationAbort;
19
24
  private readonly payments;
20
25
  private watchManager?;
21
26
  private watchConfiguration;
@@ -36,6 +41,8 @@ export declare class AttoApplication {
36
41
  sendRetry?: SendRetry;
37
42
  globalDirectory?: GlobalDirectory;
38
43
  onDestination?: (binding: DestinationBinding) => void;
44
+ workExecution?: WorkExecution;
45
+ receivingAllowed?: () => boolean;
39
46
  });
40
47
  private settings;
41
48
  private addresses;
@@ -133,6 +140,10 @@ export declare class AttoApplication {
133
140
  private invoke;
134
141
  call(name: string, input?: Record<string, unknown>): Promise<unknown>;
135
142
  start(): Promise<void>;
143
+ /** Terminal-only detached receiver control; it never changes MCP approval. */
144
+ startBackgroundReceiver(): Promise<import("../wallet/background-receive.js").BackgroundReceiveStatus>;
145
+ stopBackgroundReceiver(): Promise<import("../wallet/background-receive.js").BackgroundReceiveStatus>;
146
+ resumeWork(): void;
136
147
  private preparePoolWork;
137
148
  close(): Promise<void>;
138
149
  }
@@ -12,6 +12,7 @@ import { resolveWalletProfile } from '../storage/profiles.js';
12
12
  import { SpendLedger } from '../spending/ledger.js';
13
13
  import { Payments } from '../spending/payments.js';
14
14
  import { WalletWork } from '../wallet/work.js';
15
+ import { BackgroundReceiver } from '../wallet/background-receive.js';
15
16
  import { WatchManager } from '../watches/manager.js';
16
17
  import { AutoReceiver } from '../wallet/auto-receive.js';
17
18
  import { defaultSettings } from '../wallet/defaults.js';
@@ -30,6 +31,10 @@ export class AttoApplication {
30
31
  labels;
31
32
  auto;
32
33
  work;
34
+ background;
35
+ receivingAllowed;
36
+ preparation;
37
+ preparationAbort = new AbortController();
33
38
  payments;
34
39
  watchManager;
35
40
  watchConfiguration = '';
@@ -55,13 +60,15 @@ export class AttoApplication {
55
60
  if (!this.store.get('settings'))
56
61
  this.store.set('settings', defaultSettings());
57
62
  });
58
- this.work = new WalletWork(this.store, () => this.settings());
63
+ this.work = new WalletWork(this.store, () => this.settings(), options.workExecution);
64
+ this.background = new BackgroundReceiver(this.store);
65
+ this.receivingAllowed = options.receivingAllowed ?? (() => true);
59
66
  this.payments = new Payments(this.store, this.ledger, {
60
67
  settings: () => this.settings(), addresses: () => this.addresses(), seed: () => this.seed(),
61
68
  requireWrite: () => this.requireMcpWrite(), mcp: this.mcp,
62
69
  }, this.market, this.work, options.sendRetry, options.onDestination);
63
70
  this.auto = new AutoReceiver(() => ({
64
- settings: this.mcp && this.ledger.mcpAccess() !== 'spend' ? { ...this.settings(), autoReceive: false } : this.settings(),
71
+ settings: !this.receivingAllowed() || (this.mcp && this.ledger.mcpAccess() !== 'spend') ? { ...this.settings(), autoReceive: false } : this.settings(),
65
72
  addresses: this.addresses(),
66
73
  }), (index, hash) => this.receive({ index, hash }, true), event => options.onReceiveProgress?.(this.labels.decorate(event, this.settings().network)));
67
74
  }
@@ -178,6 +185,8 @@ export class AttoApplication {
178
185
  this.requireSession();
179
186
  if (this.mcp)
180
187
  throw new AttoError('LOCAL_APPROVAL_REQUIRED', 'Wallet reset requires approval in a local terminal.');
188
+ if (this.background.status().state !== 'stopped')
189
+ throw new AttoError('WALLET_BUSY', 'Stop the background receiver before resetting this wallet.');
181
190
  if (this.started || this.calls.size || this.recoveryReads || this.store.busy
182
191
  || this.watchManager?.list().some(watch => ['running', 'reconnecting'].includes(watch.status))) {
183
192
  throw new AttoError('WALLET_BUSY', 'Stop active wallet operations and reset from a new terminal command.');
@@ -202,7 +211,7 @@ export class AttoApplication {
202
211
  try {
203
212
  // A previous completed send may still have speculative public work queued.
204
213
  // Stop it before clearing state; normal calls cannot start during reset.
205
- await this.work.close();
214
+ await this.work.cancel();
206
215
  await this.store.withExclusiveReset(() => this.store.withWalletLock(async () => {
207
216
  if ((this.identity()?.fingerprint ?? null) !== expectedFingerprint) {
208
217
  throw new AttoError('WALLET_CHANGED', 'The wallet changed after confirmation. Review it before resetting again.');
@@ -353,6 +362,8 @@ export class AttoApplication {
353
362
  return found;
354
363
  }
355
364
  async receive(request, automatic = false) {
365
+ if (automatic && !this.receivingAllowed())
366
+ throw new AttoError('AUTO_RECEIVE_DISABLED', 'Automatic receiving is stopping.');
356
367
  const index = request.index ?? 0;
357
368
  const operation = async () => {
358
369
  await this.store.withWalletLock(async () => {
@@ -374,6 +385,7 @@ export class AttoApplication {
374
385
  if (transaction && transaction.hash.toString() === prior.blockHash && await transaction.isValid()) {
375
386
  const result = this.result(transaction, 'received');
376
387
  this.store.set(key, { ...prior, result });
388
+ this.work.prepareConfirmed(transaction.block);
377
389
  return result;
378
390
  }
379
391
  // Refresh pending state below: an unconsumed send permits retrying receive
@@ -403,9 +415,7 @@ export class AttoApplication {
403
415
  const transaction = await wallet.receive(receivable, parseAddress(request.representative ?? this.settings().representative), null);
404
416
  const result = { ...this.result(transaction, 'received'), index, amount: amountOutput(receivable.amount.toString()) };
405
417
  this.store.set(key, { hash, index, blockHash: transaction.hash.toString(), result });
406
- const account = await wallet.getAccountByIndex(toAttoIndex(index));
407
- if (account)
408
- this.work.prepare([account]);
418
+ this.work.prepareConfirmed(transaction.block);
409
419
  return result;
410
420
  }
411
421
  finally {
@@ -455,9 +465,7 @@ export class AttoApplication {
455
465
  }, this.work.worker());
456
466
  wallet = execution.wallet;
457
467
  const transaction = await wallet.change(toAttoIndex(index), parseAddress(representative), null);
458
- const account = await wallet.getAccountByIndex(toAttoIndex(index));
459
- if (account)
460
- this.work.prepare([account]);
468
+ this.work.prepareConfirmed(transaction.block);
461
469
  return this.result(transaction, 'representative_changed');
462
470
  }
463
471
  finally {
@@ -486,7 +494,7 @@ export class AttoApplication {
486
494
  const reader = this.reader();
487
495
  switch (name) {
488
496
  case 'doctor': return runDoctor({ directory: this.store.directory, access: this.mcp ? 'mcp' : undefined, signal: this.doctorAbort.signal, globalDirectory: args.globalDirectory });
489
- case 'wallet_status': return { directory: this.store.directory, initialized: Boolean(this.identity()), identity: this.identity() ?? null, settings: this.settings(), addresses: this.addresses(), autoReceive: this.auto.status(), mcpAccess: this.ledger.mcpAccess(), pool: this.ledger.pool(), pendingSends: this.ledger.pending().map(({ id, hash, status }) => ({ requestId: id, hash, status })), resetPending: Boolean(this.store.get(RESET_KEY)) };
497
+ case 'wallet_status': return { directory: this.store.directory, initialized: Boolean(this.identity()), identity: this.identity() ?? null, settings: this.settings(), addresses: this.addresses(), autoReceive: this.auto.status(), backgroundReceive: this.background.status(), mcpAccess: this.ledger.mcpAccess(), pool: this.ledger.pool(), pendingSends: this.ledger.pending().map(({ id, hash, status }) => ({ requestId: id, hash, status })), resetPending: Boolean(this.store.get(RESET_KEY)) };
490
498
  case 'wallet_configure': return this.configure(args);
491
499
  case 'address_list': return { addresses: this.addresses() };
492
500
  case 'address_add': return this.store.withWalletLock(async () => {
@@ -628,14 +636,29 @@ export class AttoApplication {
628
636
  this.requireReady();
629
637
  this.started = true;
630
638
  this.auto.start();
631
- await this.preparePoolWork();
639
+ this.preparation ??= this.preparePoolWork();
640
+ await this.preparation;
632
641
  }
642
+ /** Terminal-only detached receiver control; it never changes MCP approval. */
643
+ async startBackgroundReceiver() {
644
+ this.requireReady();
645
+ if (this.mcp)
646
+ throw new AttoError('LOCAL_APPROVAL_REQUIRED', 'Start background receiving through the CLI.');
647
+ return this.background.start();
648
+ }
649
+ async stopBackgroundReceiver() {
650
+ this.requireSession();
651
+ if (this.mcp)
652
+ throw new AttoError('LOCAL_APPROVAL_REQUIRED', 'Stop background receiving through the CLI.');
653
+ return this.background.stop();
654
+ }
655
+ resumeWork() { this.work.resume(); }
633
656
  async preparePoolWork() {
634
657
  if (this.mcp && this.ledger.mcpAccess() !== 'spend')
635
658
  return;
636
659
  const indexes = this.ledger.pool().indexes;
637
660
  await Promise.allSettled(this.addresses().filter(address => indexes.includes(address.index)).map(async (address) => {
638
- const account = await this.reader().account(address.address);
661
+ const account = await this.reader().account(address.address, this.preparationAbort.signal);
639
662
  if (account && !this.closed)
640
663
  this.work.prepare([account]);
641
664
  }));
@@ -645,7 +668,9 @@ export class AttoApplication {
645
668
  return;
646
669
  this.closed = true;
647
670
  this.doctorAbort.abort();
671
+ this.preparationAbort.abort();
648
672
  await this.auto.close();
673
+ await this.preparation;
649
674
  await Promise.allSettled([...this.calls]);
650
675
  await this.watchManager?.close();
651
676
  await this.work.close();
package/dist/cli/cli.js CHANGED
@@ -53,7 +53,7 @@ export async function runCli(argv = process.argv) {
53
53
  const currentVersion = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version;
54
54
  let application;
55
55
  let sendRequestId;
56
- const app = (onReceiveProgress, sendRetry, onDestination) => application ??= new AttoApplication({ directory: program.opts().dataDir, onReceiveProgress, sendRetry, onDestination });
56
+ const app = (onReceiveProgress, sendRetry, onDestination) => application ??= new AttoApplication({ directory: program.opts().dataDir, onReceiveProgress, sendRetry, onDestination, workExecution: onReceiveProgress ? 'in-process' : 'detached' });
57
57
  const jsonOutput = (value) => { process.stdout.write(`${JSON.stringify(value, (_, value) => typeof value === 'bigint' ? value.toString() : value)}\n`); };
58
58
  const output = (result, operation) => {
59
59
  if (program.opts().json)
@@ -67,6 +67,7 @@ export async function runCli(argv = process.argv) {
67
67
  return diagnose(input);
68
68
  }
69
69
  const result = await app().call(name, compact(input));
70
+ app().resumeWork();
70
71
  output(result, name);
71
72
  };
72
73
  const waitForSignal = async (work) => {
@@ -166,8 +167,14 @@ export async function runCli(argv = process.argv) {
166
167
  if (program.opts().json)
167
168
  output({ displayed: true });
168
169
  });
169
- wallet.command('receive').description('Keep automatic receiving running until Ctrl+C')
170
+ const receiveCommand = wallet.command('receive').description('Keep automatic receiving running until Ctrl+C')
171
+ .option('--background', 'Keep receiving after this terminal exits; restart manually after reboot')
170
172
  .action(async () => {
173
+ if (activeCommand.opts().background) {
174
+ const status = await app().startBackgroundReceiver();
175
+ output({ backgroundReceive: status }, 'wallet_receive');
176
+ return;
177
+ }
171
178
  const receiving = app(event => output(event, 'receive_progress'));
172
179
  const status = await receiving.call('wallet_status');
173
180
  if (!status.initialized)
@@ -183,6 +190,8 @@ export async function runCli(argv = process.argv) {
183
190
  await new Promise(resolve => signal.addEventListener('abort', () => resolve(), { once: true }));
184
191
  });
185
192
  });
193
+ receiveCommand.command('status').description('Read detached receiver status').action(() => call('wallet_status'));
194
+ receiveCommand.command('stop').description('Request detached receiver shutdown').action(async () => output({ backgroundReceive: await app().stopBackgroundReceiver() }, 'wallet_receive'));
186
195
  const address = program.command('address').description('Manage mnemonic-derived public addresses');
187
196
  address.command('list').description('List all saved addresses and their activation state').action(() => call('address_list'));
188
197
  address.command('add').description('Add and activate the next account address; receiving funds opens it on the network').action(() => call('address_add'));
@@ -254,6 +263,7 @@ export async function runCli(argv = process.argv) {
254
263
  signal,
255
264
  onRetry: (error, delayMs) => progress({ requestId: sendRequestId, error: errorResult(error), retryInMs: delayMs }, `${error.message} Retrying in ${delayMs / 1000}s. Press Ctrl+C to stop.\n`),
256
265
  }, binding => progress({ requestId: sendRequestId, destination: binding.address, network: binding.network, ...(binding.label ? { personalName: binding.label } : {}) })).call('send', input);
266
+ app().resumeWork();
257
267
  output(result, 'send');
258
268
  });
259
269
  });
@@ -386,6 +396,7 @@ export async function runCli(argv = process.argv) {
386
396
  'atto wallet configure': 'atto wallet configure --node-url https://node-public.live.application.atto.cash --auto-receive',
387
397
  'atto wallet create': 'atto wallet create', 'atto wallet import': 'atto wallet import',
388
398
  'atto wallet reset': 'atto wallet reset', 'atto wallet backup': 'atto wallet backup', 'atto wallet receive': 'atto wallet receive',
399
+ 'atto wallet receive status': 'atto wallet receive status', 'atto wallet receive stop': 'atto wallet receive stop',
389
400
  'atto address': 'atto address add\n atto address list', 'atto address list': 'atto address list',
390
401
  'atto address add': 'atto address add', 'atto address derive': 'atto address derive 3',
391
402
  'atto address activate': 'atto address activate 1', 'atto address deactivate': 'atto address deactivate 1',
@@ -8,7 +8,6 @@ interface ProposalOptions {
8
8
  export declare function approveLimitsProposal({ id, directory }: ProposalOptions): Promise<unknown>;
9
9
  export declare function rejectLimitsProposal({ id, directory }: ProposalOptions): Promise<unknown>;
10
10
  /** Returns only public host configuration; recovery material is displayed directly on the TTY. */
11
- export declare function setupMcp({ version, directory }: {
12
- version: string;
11
+ export declare function setupMcp({ directory }: {
13
12
  directory?: string;
14
13
  }): Promise<unknown>;
@@ -107,7 +107,7 @@ async function configurePool(current) {
107
107
  return { indexes, consolidate };
108
108
  }
109
109
  /** Returns only public host configuration; recovery material is displayed directly on the TTY. */
110
- export async function setupMcp({ version, directory }) {
110
+ export async function setupMcp({ directory }) {
111
111
  requireTerminal();
112
112
  process.stderr.write('Atto MCP setup. Recovery phrases stay in your OS password store and this terminal.\n');
113
113
  const wallet = await choose('Wallet: [1] Dedicated MCP wallet (default), [2] Existing CLI wallet: ');
@@ -136,7 +136,7 @@ export async function setupMcp({ version, directory }) {
136
136
  const { proposal } = await application.call('limits_propose', { policy, access, pool });
137
137
  await approve(application, proposal.id);
138
138
  process.stderr.write('\nAdd this public configuration to your MCP client. The explicit data directory preserves your wallet selection.\n');
139
- return { mcpServers: { atto: { command: 'npx', args: ['--yes', `@attocash/mcp@${version}`, '--data-dir', profile.directory] } } };
139
+ return { mcpServers: { atto: { command: 'npx', args: ['--yes', '@attocash/mcp@latest', '--data-dir', profile.directory] } } };
140
140
  }
141
141
  finally {
142
142
  await application.close();
@@ -137,7 +137,7 @@ function formatUnlabeledHumanResult(result, operation) {
137
137
  address: record(result.identity) ? result.identity.address : null,
138
138
  network: result.settings.network,
139
139
  automaticReceiving: result.settings.autoReceive ? 'Enabled' : 'Disabled',
140
- ...(operation === 'wallet_status' ? { receivingInThisProcess: receiver.running, lastReceiveError: receiver.lastError } : {}),
140
+ ...(operation === 'wallet_status' ? { receivingInThisProcess: receiver.running, lastReceiveError: receiver.lastError, backgroundReceiver: result.backgroundReceive } : {}),
141
141
  representative: result.settings.representative,
142
142
  minReceiveRaw: result.settings.minReceiveRaw,
143
143
  nodeUrl: result.settings.nodeUrl,
@@ -333,15 +333,11 @@ export class Payments {
333
333
  throw new AttoError('RECEIVABLE_NOT_PENDING', 'The consolidation transfer is not available to receive yet. Resume this payment with the same request ID.');
334
334
  }
335
335
  const transaction = await execution.wallet.receive(receivable, parseAddress(settings.representative), null);
336
- const account = await execution.wallet.getAccountByIndex(toAttoIndex(index));
337
- if (account)
338
- this.work.prepare([account]);
336
+ this.work.prepareConfirmed(transaction.block);
339
337
  return transaction;
340
338
  }
341
339
  const transaction = await execution.wallet.sendByIndex(toAttoIndex(index), parseAddress(step?.destination ?? record.destination), AttoAmount.from(AttoUnit.RAW, step?.raw ?? record.raw), null);
342
- const account = await execution.wallet.getAccountByIndex(toAttoIndex(index));
343
- if (account)
344
- this.work.prepare([account]);
340
+ this.work.prepareConfirmed(transaction.block);
345
341
  return transaction;
346
342
  }
347
343
  finally {
@@ -390,6 +386,7 @@ export class Payments {
390
386
  });
391
387
  if (outcome.status === 'rejected')
392
388
  return;
389
+ this.work.prepareConfirmed(outcome.transaction.block);
393
390
  }
394
391
  if (!record.hash) {
395
392
  if (!record.plan)
@@ -403,6 +400,8 @@ export class Payments {
403
400
  else if (outcome.status === 'rejected')
404
401
  this.ledger.reject(record.id);
405
402
  });
403
+ if (outcome.status === 'published')
404
+ this.work.prepareConfirmed(outcome.transaction.block);
406
405
  }
407
406
  async poolStatus() {
408
407
  const pool = this.ledger.pool();
@@ -9,6 +9,7 @@ export declare class StateStore {
9
9
  private closed;
10
10
  private lockRequests;
11
11
  private exclusiveReset;
12
+ private auxiliaryLocks;
12
13
  get busy(): boolean;
13
14
  constructor(directory: string);
14
15
  get<T>(key: string): T | undefined;
@@ -28,5 +29,11 @@ export declare class StateStore {
28
29
  /** Acquire account locks first; short wallet reservations may run inside fn.
29
30
  * Closing the connection (including process death) releases every lock. */
30
31
  withAccountLocks<T>(indexes: readonly number[], fn: () => Promise<T>): Promise<T>;
32
+ /** A process lifetime lock. Unlike the wallet mutation lock this has no
33
+ * bearing on ordinary commands, and SQLite releases it if the owner dies. */
34
+ tryProcessLock(name: string): (() => void) | undefined;
35
+ /** Computation locks never share the wallet/account mutation databases. */
36
+ tryWorkLock(target: string): (() => void) | undefined;
37
+ private tryAuxiliaryLock;
31
38
  close(): void;
32
39
  }
@@ -4,6 +4,20 @@ import { join, resolve } from 'node:path';
4
4
  import { DatabaseSync } from 'node:sqlite';
5
5
  import { setTimeout as delay } from 'node:timers/promises';
6
6
  import { AttoError } from '../domain/errors.js';
7
+ import { createHash } from 'node:crypto';
8
+ function ensureDatabaseFile(path) {
9
+ // Closing any raw descriptor for an existing SQLite file drops this process's
10
+ // POSIX locks, even when a different connection still owns the lease.
11
+ try {
12
+ closeSync(openSync(path, 'ax', 0o600));
13
+ }
14
+ catch (error) {
15
+ if (error.code !== 'EEXIST')
16
+ throw error;
17
+ }
18
+ if (process.platform !== 'win32')
19
+ chmodSync(path, 0o600);
20
+ }
7
21
  export function defaultDataDirectory() {
8
22
  if (process.platform === 'win32')
9
23
  return join(process.env.LOCALAPPDATA || join(homedir(), 'AppData', 'Local'), 'Atto MCP');
@@ -21,6 +35,7 @@ export class StateStore {
21
35
  closed = false;
22
36
  lockRequests = 0;
23
37
  exclusiveReset = false;
38
+ auxiliaryLocks = 0;
24
39
  get busy() { return this.lockRequests !== 0 || this.exclusiveReset; }
25
40
  constructor(directory) {
26
41
  this.directory = resolve(directory);
@@ -28,9 +43,7 @@ export class StateStore {
28
43
  const connections = [];
29
44
  const open = (name) => {
30
45
  const path = join(this.directory, name);
31
- closeSync(openSync(path, 'a', 0o600));
32
- if (process.platform !== 'win32')
33
- chmodSync(path, 0o600);
46
+ ensureDatabaseFile(path);
34
47
  const connection = new DatabaseSync(path);
35
48
  connections.push(connection);
36
49
  return connection;
@@ -93,7 +106,7 @@ export class StateStore {
93
106
  * store closes, even if another session prevents acquiring exclusivity. */
94
107
  async withExclusiveReset(fn) {
95
108
  this.requireOpen();
96
- if (this.lockRequests !== 0 || this.exclusiveReset)
109
+ if (this.lockRequests !== 0 || this.auxiliaryLocks !== 0 || this.exclusiveReset)
97
110
  throw new AttoError('WALLET_BUSY', 'Wait for wallet operations before resetting this profile.');
98
111
  try {
99
112
  this.lifecycle.exec('ROLLBACK;');
@@ -192,9 +205,7 @@ export class StateStore {
192
205
  // Sorted acquisition prevents cycles when payments consolidate accounts.
193
206
  for (const index of [...new Set(indexes)].sort((left, right) => left - right)) {
194
207
  const file = join(directory, `${index}.sqlite`);
195
- closeSync(openSync(file, 'a', 0o600));
196
- if (process.platform !== 'win32')
197
- chmodSync(file, 0o600);
208
+ ensureDatabaseFile(file);
198
209
  const connection = new DatabaseSync(file);
199
210
  connections.push(connection);
200
211
  connection.exec('PRAGMA busy_timeout = 0; BEGIN IMMEDIATE;');
@@ -242,10 +253,52 @@ export class StateStore {
242
253
  }
243
254
  }
244
255
  }
256
+ /** A process lifetime lock. Unlike the wallet mutation lock this has no
257
+ * bearing on ordinary commands, and SQLite releases it if the owner dies. */
258
+ tryProcessLock(name) {
259
+ if (!/^[a-z0-9-]{1,64}$/i.test(name))
260
+ throw new AttoError('INVALID_LOCK', 'Invalid process lock name.');
261
+ return this.tryAuxiliaryLock('process-locks', name);
262
+ }
263
+ /** Computation locks never share the wallet/account mutation databases. */
264
+ tryWorkLock(target) {
265
+ return this.tryAuxiliaryLock('work-locks', createHash('sha256').update(target).digest('hex'));
266
+ }
267
+ tryAuxiliaryLock(folder, name) {
268
+ this.requireOpen();
269
+ const directory = join(this.directory, folder);
270
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
271
+ const file = join(directory, `${name}.sqlite`);
272
+ ensureDatabaseFile(file);
273
+ const connection = new DatabaseSync(file);
274
+ try {
275
+ connection.exec('PRAGMA busy_timeout = 0; BEGIN IMMEDIATE;');
276
+ }
277
+ catch (error) {
278
+ connection.close();
279
+ const code = error.errcode;
280
+ if (code === 5 || code === 6)
281
+ return undefined;
282
+ throw error;
283
+ }
284
+ this.auxiliaryLocks++;
285
+ let held = true;
286
+ return () => {
287
+ if (!held)
288
+ return;
289
+ held = false;
290
+ try {
291
+ connection.close();
292
+ }
293
+ finally {
294
+ this.auxiliaryLocks--;
295
+ }
296
+ };
297
+ }
245
298
  close() {
246
299
  if (this.closed)
247
300
  return;
248
- if (this.lockRequests !== 0)
301
+ if (this.lockRequests !== 0 || this.auxiliaryLocks !== 0)
249
302
  throw new AttoError('WALLET_BUSY', 'Wait for wallet operations before closing state.');
250
303
  this.coordination.close();
251
304
  this.state.close();
@@ -0,0 +1,30 @@
1
+ import { errorResult } from '../domain/errors.js';
2
+ import type { StateStore } from '../storage/state.js';
3
+ import type { ReceiveProgress } from './auto-receive.js';
4
+ interface ReceiverRecord {
5
+ token: string;
6
+ desired: boolean;
7
+ state: 'starting' | 'running' | 'stopping' | 'stopped';
8
+ lastError: ReturnType<typeof errorResult> | null;
9
+ }
10
+ export interface BackgroundReceiveStatus {
11
+ state: 'running' | 'stopping' | 'stopped';
12
+ lastError: ReturnType<typeof errorResult> | null;
13
+ }
14
+ export declare function requireReceivingProfile(store: StateStore): void;
15
+ /** Profile control and observable process ownership. Persisted intent alone
16
+ * cannot make a receiver live or restart it after restoring a backup. */
17
+ export declare class BackgroundReceiver {
18
+ private readonly store;
19
+ constructor(store: StateStore);
20
+ status(): BackgroundReceiveStatus;
21
+ start(): Promise<BackgroundReceiveStatus>;
22
+ stop(): Promise<BackgroundReceiveStatus>;
23
+ private launch;
24
+ /** A stale launch token cannot restart a stopped/reset/restored profile. */
25
+ claim(token: string): (() => void) | undefined;
26
+ stopping(token: string): boolean;
27
+ update(token: string, update: Partial<Omit<ReceiverRecord, 'token'>>): void;
28
+ progress(token: string, event: ReceiveProgress): void;
29
+ }
30
+ export {};
@@ -0,0 +1,117 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { AttoError, errorResult } from '../domain/errors.js';
5
+ const KEY = 'receive.background';
6
+ export function requireReceivingProfile(store) {
7
+ if (store.get('wallet.reset'))
8
+ throw new AttoError('WALLET_RESET_REQUIRED', 'Finish wallet reset before starting receiving.');
9
+ if (!store.get('identity'))
10
+ throw new AttoError('WALLET_NOT_INITIALIZED', 'Create or import a wallet before receiving: atto wallet create or atto wallet import.');
11
+ if (!store.get('settings')?.autoReceive)
12
+ throw new AttoError('AUTO_RECEIVE_DISABLED', 'Automatic receiving is disabled. Enable it with atto wallet configure --auto-receive.');
13
+ if (!store.get('addresses')?.some(address => address.active))
14
+ throw new AttoError('NO_ACTIVE_ACCOUNTS', 'Add or activate a wallet address before receiving.');
15
+ }
16
+ /** Profile control and observable process ownership. Persisted intent alone
17
+ * cannot make a receiver live or restart it after restoring a backup. */
18
+ export class BackgroundReceiver {
19
+ store;
20
+ constructor(store) {
21
+ this.store = store;
22
+ }
23
+ status() {
24
+ const record = this.store.get(KEY);
25
+ if (!record)
26
+ return { state: 'stopped', lastError: null };
27
+ const release = this.store.tryProcessLock('receive-daemon');
28
+ const live = !release;
29
+ release?.();
30
+ const lastError = record.lastError ?? (!live && record.state !== 'stopped'
31
+ ? errorResult(new AttoError('RECEIVER_EXITED', 'The background receiver exited. Start it again with atto wallet receive --background.')) : null);
32
+ return { state: live ? record.desired ? 'running' : 'stopping' : 'stopped', lastError };
33
+ }
34
+ async start() {
35
+ // Serialize launch/stop decisions, including startup acknowledgment. The
36
+ // child performs only local initialization before acknowledging this call.
37
+ return this.store.withWalletLock(async () => {
38
+ const status = this.status();
39
+ if (status.state !== 'stopped')
40
+ return status;
41
+ requireReceivingProfile(this.store);
42
+ const token = randomUUID();
43
+ this.store.set(KEY, { token, desired: true, state: 'starting', lastError: null });
44
+ try {
45
+ await this.launch(token);
46
+ }
47
+ catch (error) {
48
+ this.update(token, { desired: false, state: 'stopping', lastError: errorResult(error) });
49
+ throw error;
50
+ }
51
+ return this.status();
52
+ });
53
+ }
54
+ async stop() {
55
+ return this.store.withWalletLock(async () => {
56
+ const status = this.status();
57
+ const record = this.store.get(KEY);
58
+ if (record)
59
+ this.store.set(KEY, { ...record, desired: false, state: status.state === 'stopped' ? 'stopped' : 'stopping', lastError: status.lastError });
60
+ return { ...status, state: status.state === 'stopped' ? 'stopped' : 'stopping' };
61
+ });
62
+ }
63
+ launch(token) {
64
+ return new Promise((resolve, reject) => {
65
+ const child = spawn(process.execPath, [fileURLToPath(new URL('./receive-daemon.js', import.meta.url)), this.store.directory, token], {
66
+ detached: true, stdio: ['ignore', 'ignore', 'ignore', 'ipc'], windowsHide: true,
67
+ });
68
+ let settled = false;
69
+ const finish = (error) => {
70
+ if (settled)
71
+ return;
72
+ settled = true;
73
+ clearTimeout(timer);
74
+ if (child.connected)
75
+ child.disconnect();
76
+ child.unref();
77
+ if (error)
78
+ reject(error);
79
+ else
80
+ resolve();
81
+ };
82
+ const timer = setTimeout(() => finish(new AttoError('RECEIVER_START_TIMEOUT', 'Background receiver initialization timed out. Check wallet receive status before retrying.')), 5000);
83
+ child.on('error', () => finish(new AttoError('RECEIVER_START_FAILED', 'The background receiver could not start. Check the installed CLI and profile permissions.')));
84
+ child.once('exit', () => finish(new AttoError('RECEIVER_START_FAILED', 'The background receiver exited before initialization completed. Check wallet receive status.')));
85
+ child.on('message', (message) => {
86
+ if (message && typeof message === 'object' && 'ready' in message && message.ready === token)
87
+ finish();
88
+ });
89
+ child.unref();
90
+ });
91
+ }
92
+ /** A stale launch token cannot restart a stopped/reset/restored profile. */
93
+ claim(token) {
94
+ return this.store.transaction(() => {
95
+ if (this.stopping(token))
96
+ return;
97
+ return this.store.tryProcessLock('receive-daemon');
98
+ });
99
+ }
100
+ stopping(token) {
101
+ const record = this.store.get(KEY);
102
+ return record?.token !== token || !record.desired;
103
+ }
104
+ update(token, update) {
105
+ this.store.transaction(() => {
106
+ const record = this.store.get(KEY);
107
+ if (record?.token === token)
108
+ this.store.set(KEY, { ...record, ...update });
109
+ });
110
+ }
111
+ progress(token, event) {
112
+ if (event.event === 'retry' || event.event === 'reconnecting')
113
+ this.update(token, { lastError: event.error });
114
+ else if (event.event === 'received')
115
+ this.update(token, { lastError: null });
116
+ }
117
+ }