amalgm 0.1.78 → 0.1.79

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,8 +9,8 @@ amalgm doctor
9
9
  amalgm start
10
10
  ```
11
11
 
12
- To move an existing install to the newest stable published build, run `amalgm update`.
13
- Set `AMALGM_AUTO_UPDATE=1` when running `amalgm start` to let the CLI update itself to the latest stable release before launching.
12
+ To move an existing install to the newest stable published build immediately, run `amalgm update`.
13
+ `amalgm start` checks for updates before launch by default; set `AMALGM_AUTO_UPDATE=0` or `AMALGM_DISABLE_AUTO_UPDATE=1` to opt out.
14
14
 
15
15
  `amalgm login` opens a browser approval page, then registers the machine and stores its local tunnel/computer record in `~/.amalgm/computer.json`.
16
16
 
@@ -34,6 +34,8 @@ amalgm run
34
34
  - Linux host with real user systemd: systemd user service
35
35
  - random/container shell where no service manager is available: portable best-effort watchdog
36
36
 
37
+ Running service-managed runtimes also check for updates in the background. Normal updates restart only when chat/tunnel work is idle; critical updates can restart immediately. If the runtime is not service-managed, background updates are installed but the running process stays on the old code until it is manually restarted.
38
+
37
39
  The runtime supervisor keeps the declared local essentials alive:
38
40
 
39
41
  - port monitor on `8081`
package/lib/cli.js CHANGED
@@ -28,7 +28,6 @@ const {
28
28
  } = require('./paths');
29
29
  const {
30
30
  normalizeRuntimeLabel,
31
- npmTagForRuntimeLabel,
32
31
  sanitizeScopeSegment,
33
32
  } = require('./runtime-identity');
34
33
  const { startSupervisor } = require('./supervisor');
@@ -65,6 +64,10 @@ const {
65
64
  ensureAgentCommandShims,
66
65
  ensureNativeBinaries,
67
66
  } = require('../runtime/scripts/chat-core/tooling/native-binaries');
67
+ const {
68
+ shouldAutoUpdate,
69
+ updatePackage,
70
+ } = require('./updater');
68
71
 
69
72
  const PACKAGE_VERSION = require('../package.json').version;
70
73
  const SETUP_CODE_LENGTH = 16;
@@ -107,7 +110,7 @@ function usage() {
107
110
  '',
108
111
  'Usage:',
109
112
  ' amalgm login [--app-url https://amalgm.ai] [--name "My Mac"] [--setup-code ABCD-EFGH-JKLM-NPQR] [--no-open] [--no-poll]',
110
- ' amalgm start [--foreground] [--local-only] [--branch main|preview] [--user <user-id>]',
113
+ ' amalgm start [--foreground] [--local-only] [--no-auto-update] [--branch main|preview] [--user <user-id>]',
111
114
  ' amalgm run [--local-only]',
112
115
  ' amalgm stop',
113
116
  ' amalgm status',
@@ -119,7 +122,8 @@ function usage() {
119
122
  '',
120
123
  'Environment:',
121
124
  ' AMALGM_APP_URL Web app base URL for login/register',
122
- ' AMALGM_AUTO_UPDATE Set to 1 to update to the latest stable release before start',
125
+ ' AMALGM_AUTO_UPDATE Auto-update before start unless set to 0/false/off',
126
+ ' AMALGM_DISABLE_AUTO_UPDATE Set to 1 to disable startup/background auto-updates',
123
127
  ' AMALGM_DIR Explicit runtime state dir override',
124
128
  ' AMALGM_HOME Runtime state root (default ~/.amalgm)',
125
129
  ' AMALGM_RUNTIME_LABEL Runtime label: main, preview, or local',
@@ -197,13 +201,21 @@ function hasRequiredComputerFields(record) {
197
201
  }
198
202
 
199
203
  const SAFE_DAEMON_ENV_KEYS = [
204
+ 'AMALGM_AUTO_UPDATE',
200
205
  'AMALGM_BIND_HOST',
201
206
  'AMALGM_BRANCH',
202
207
  'AMALGM_DEFAULT_CWD',
208
+ 'AMALGM_DISABLE_AUTO_UPDATE',
203
209
  'AMALGM_GATEWAY_PORT',
204
210
  'AMALGM_HOME',
205
211
  'AMALGM_MCP_PORT',
206
212
  'AMALGM_NATIVE_NODE_MODULES',
213
+ 'AMALGM_UPDATE_CHECK_INTERVAL_MS',
214
+ 'AMALGM_UPDATE_CRITICAL',
215
+ 'AMALGM_UPDATE_IDLE_TIMEOUT_MS',
216
+ 'AMALGM_UPDATE_INITIAL_DELAY_MS',
217
+ 'AMALGM_UPDATE_IDLE_POLL_MS',
218
+ 'AMALGM_AUTO_UPDATE_CRITICAL',
207
219
  'AMALGM_RUNTIME_INSTANCE_ID',
208
220
  'AMALGM_RUNTIME_LABEL',
209
221
  'AMALGM_RUNTIME_STATE_DIR',
@@ -421,91 +433,6 @@ function commandExists(command) {
421
433
  });
422
434
  }
423
435
 
424
- function parseSemver(value) {
425
- const [core, prerelease = ''] = String(value || '').trim().replace(/^v/, '').split('-', 2);
426
- const parts = core.split('.').map((part) => Number.parseInt(part, 10));
427
- return {
428
- major: Number.isFinite(parts[0]) ? parts[0] : 0,
429
- minor: Number.isFinite(parts[1]) ? parts[1] : 0,
430
- patch: Number.isFinite(parts[2]) ? parts[2] : 0,
431
- prerelease: prerelease ? prerelease.split('.') : [],
432
- };
433
- }
434
-
435
- function comparePrerelease(left, right) {
436
- if (left.length === 0 && right.length === 0) return 0;
437
- if (left.length === 0) return 1;
438
- if (right.length === 0) return -1;
439
-
440
- const length = Math.max(left.length, right.length);
441
- for (let i = 0; i < length; i += 1) {
442
- const a = left[i];
443
- const b = right[i];
444
- if (a === undefined) return -1;
445
- if (b === undefined) return 1;
446
- const aNum = /^\d+$/.test(a) ? Number.parseInt(a, 10) : null;
447
- const bNum = /^\d+$/.test(b) ? Number.parseInt(b, 10) : null;
448
- if (aNum !== null && bNum !== null && aNum !== bNum) return aNum > bNum ? 1 : -1;
449
- if (aNum !== null && bNum === null) return -1;
450
- if (aNum === null && bNum !== null) return 1;
451
- if (a !== b) return a > b ? 1 : -1;
452
- }
453
- return 0;
454
- }
455
-
456
- function compareVersions(left, right) {
457
- const a = parseSemver(left);
458
- const b = parseSemver(right);
459
- for (const key of ['major', 'minor', 'patch']) {
460
- if (a[key] !== b[key]) return a[key] > b[key] ? 1 : -1;
461
- }
462
- return comparePrerelease(a.prerelease, b.prerelease);
463
- }
464
-
465
- function npmCommand() {
466
- return process.platform === 'win32' ? 'npm.cmd' : 'npm';
467
- }
468
-
469
- function npmInstallTag(options = {}) {
470
- const explicit = options.tag || options.channel;
471
- if (explicit && explicit !== true) return String(explicit).replace(/^@/, '');
472
- return npmTagForRuntimeLabel(AMALGM_RUNTIME_LABEL);
473
- }
474
-
475
- function npmPackageVersionForTag(tag) {
476
- const result = spawnSync(npmCommand(), ['view', `amalgm@${tag}`, 'version', '--json'], {
477
- encoding: 'utf8',
478
- timeout: 15000,
479
- env: {
480
- ...process.env,
481
- npm_config_audit: 'false',
482
- npm_config_fund: 'false',
483
- },
484
- });
485
- if (result.status !== 0) {
486
- const detail = String(result.stderr || result.stdout || '').trim();
487
- throw new Error(`Could not check npm dist-tag "${tag}". ${detail}`);
488
- }
489
-
490
- const raw = String(result.stdout || '').trim();
491
- try {
492
- return JSON.parse(raw);
493
- } catch {
494
- return raw.replace(/^"|"$/g, '');
495
- }
496
- }
497
-
498
- function installNpmPackageTag(tag) {
499
- return spawnSync(npmCommand(), ['install', '-g', `amalgm@${tag}`], {
500
- stdio: 'inherit',
501
- env: {
502
- ...process.env,
503
- npm_config_audit: 'false',
504
- npm_config_fund: 'false',
505
- },
506
- });
507
- }
508
-
509
436
  function openBrowser(url) {
510
437
  const command =
511
438
  process.platform === 'darwin'
@@ -819,22 +746,21 @@ function ensureAgentRuntimeCommands(options = {}) {
819
746
  }
820
747
 
821
748
  async function update(options = {}) {
822
- const tag = npmInstallTag(options);
823
- const targetVersion = npmPackageVersionForTag(tag);
824
- if (!targetVersion) throw new Error(`npm did not return a version for amalgm@${tag}`);
749
+ const result = await updatePackage({
750
+ ...options,
751
+ currentVersion: PACKAGE_VERSION,
752
+ onOutput: (text, stream) => {
753
+ if (stream === 'stderr') process.stderr.write(text);
754
+ else process.stdout.write(text);
755
+ },
756
+ });
825
757
 
826
- if (compareVersions(targetVersion, PACKAGE_VERSION) <= 0) {
827
- console.log(`Amalgm is already current for ${tag}: ${PACKAGE_VERSION}`);
758
+ if (!result.updated) {
759
+ console.log(`Amalgm is already current for ${result.tag}: ${PACKAGE_VERSION}`);
828
760
  return false;
829
761
  }
830
762
 
831
- console.log(`Updating Amalgm from ${PACKAGE_VERSION} to ${targetVersion} (${tag}).`);
832
- const result = installNpmPackageTag(tag);
833
- if (result.status !== 0) {
834
- throw new Error(`npm install -g amalgm@${tag} failed with status ${result.status ?? result.signal ?? 'unknown'}`);
835
- }
836
-
837
- console.log(`Amalgm updated to ${targetVersion}.`);
763
+ console.log(`Amalgm updated from ${PACKAGE_VERSION} to ${result.targetVersion} (${result.tag}).`);
838
764
  const pid = readPid();
839
765
  if (isPidRunning(pid)) {
840
766
  console.log('A previous Amalgm daemon is still running. Run `amalgm stop && amalgm start` to restart it with the new package.');
@@ -843,7 +769,9 @@ async function update(options = {}) {
843
769
  }
844
770
 
845
771
  async function updateBeforeStartIfRequested(options = {}) {
846
- const requested = options['auto-update'] || process.env.AMALGM_AUTO_UPDATE === '1';
772
+ const requested = !options['no-auto-update']
773
+ && process.env.AMALGM_AUTO_UPDATE_RELAUNCHED !== '1'
774
+ && shouldAutoUpdate();
847
775
  if (!requested) return false;
848
776
 
849
777
  try {
@@ -854,11 +782,28 @@ async function updateBeforeStartIfRequested(options = {}) {
854
782
  }
855
783
  }
856
784
 
785
+ function relaunchCurrentCommandAfterUpdate() {
786
+ console.log('Launching the updated Amalgm package.');
787
+ const result = spawnSync(process.execPath, process.argv.slice(1), {
788
+ stdio: 'inherit',
789
+ env: {
790
+ ...process.env,
791
+ AMALGM_AUTO_UPDATE_RELAUNCHED: '1',
792
+ },
793
+ });
794
+ if (result.error) throw result.error;
795
+ if (result.signal) {
796
+ process.kill(process.pid, result.signal);
797
+ return 1;
798
+ }
799
+ return Number.isInteger(result.status) ? result.status : 0;
800
+ }
801
+
857
802
  async function start(options) {
858
803
  ensureBaseDirs();
859
804
 
860
805
  if (await updateBeforeStartIfRequested(options)) {
861
- console.log('Run `amalgm start` again to launch the updated package.');
806
+ process.exitCode = relaunchCurrentCommandAfterUpdate();
862
807
  return;
863
808
  }
864
809
 
package/lib/paths.js CHANGED
@@ -40,6 +40,7 @@ const AUTH_FILE = path.join(AMALGM_DIR, 'auth.json');
40
40
  const RUNTIME_TOKEN_FILE = path.join(AMALGM_RUNTIME_STATE_DIR, 'runtime-token.json');
41
41
  const PID_FILE = path.join(AMALGM_RUNTIME_STATE_DIR, 'amalgm.pid');
42
42
  const RUNTIME_STATE_FILE = path.join(AMALGM_RUNTIME_STATE_DIR, 'runtime-state.json');
43
+ const AUTO_UPDATE_STATE_FILE = path.join(AMALGM_RUNTIME_STATE_DIR, 'auto-update-state.json');
43
44
  const LOG_DIR = path.join(AMALGM_RUNTIME_STATE_DIR, 'logs');
44
45
  const SERVICE_DIR = path.join(AMALGM_RUNTIME_STATE_DIR, 'service');
45
46
  const SERVICE_PID_FILE = path.join(AMALGM_RUNTIME_STATE_DIR, 'amalgm-service.pid');
@@ -57,6 +58,7 @@ module.exports = {
57
58
  AMALGM_RUNTIME_LABEL,
58
59
  AMALGM_RUNTIME_USER_SCOPE,
59
60
  AUTH_FILE,
61
+ AUTO_UPDATE_STATE_FILE,
60
62
  COMPUTER_FILE,
61
63
  DEFAULT_APP_URL,
62
64
  DEVICE_FILE,
package/lib/service.js CHANGED
@@ -38,9 +38,11 @@ const AMALGM_BIN = path.join(PACKAGE_ROOT, 'bin', 'amalgm.js');
38
38
  const SERVICE_ENV_KEYS = [
39
39
  'AMALGM_APP_URL',
40
40
  'AMALGM_AUTH_BACKUP_ENABLED',
41
+ 'AMALGM_AUTO_UPDATE',
41
42
  'AMALGM_BIND_HOST',
42
43
  'AMALGM_CREATE_AUTH_WATCH_DIRS',
43
44
  'AMALGM_DEFAULT_CWD',
45
+ 'AMALGM_DISABLE_AUTO_UPDATE',
44
46
  'AMALGM_GATEWAY_PORT',
45
47
  'AMALGM_HOME',
46
48
  'AMALGM_RUNTIME_INSTANCE_ID',
@@ -50,6 +52,12 @@ const SERVICE_ENV_KEYS = [
50
52
  'AMALGM_NATIVE_NODE_MODULES',
51
53
  'AMALGM_PROJECTS_DIR',
52
54
  'AMALGM_SKIP_NATIVE_INSTALL',
55
+ 'AMALGM_UPDATE_CHECK_INTERVAL_MS',
56
+ 'AMALGM_UPDATE_CRITICAL',
57
+ 'AMALGM_UPDATE_IDLE_TIMEOUT_MS',
58
+ 'AMALGM_UPDATE_INITIAL_DELAY_MS',
59
+ 'AMALGM_UPDATE_IDLE_POLL_MS',
60
+ 'AMALGM_AUTO_UPDATE_CRITICAL',
53
61
  'AMALGM_WORKSPACES_DIR',
54
62
  'CHAT_SERVER_PORT',
55
63
  'ELECTRON_RUN_AS_NODE',
package/lib/supervisor.js CHANGED
@@ -12,6 +12,7 @@ const {
12
12
  AMALGM_DIR,
13
13
  AMALGM_BRANCH,
14
14
  AMALGM_HOME,
15
+ AUTO_UPDATE_STATE_FILE,
15
16
  LOG_DIR,
16
17
  PID_FILE,
17
18
  AMALGM_RUNTIME_INSTANCE_ID,
@@ -44,6 +45,11 @@ const {
44
45
  runtimePortsState,
45
46
  runtimeServiceScripts,
46
47
  } = require('./runtime-manifest');
48
+ const {
49
+ checkForUpdate,
50
+ shouldAutoUpdate,
51
+ updatePackage,
52
+ } = require('./updater');
47
53
  const PACKAGE_VERSION = require('../package.json').version;
48
54
 
49
55
  const CHILD_RESTART_POLICY = {
@@ -61,6 +67,13 @@ const CHILD_HEALTH_POLICY = {
61
67
  maxFailures: 3,
62
68
  };
63
69
 
70
+ const AUTO_UPDATE_POLICY = {
71
+ checkIntervalMs: 15 * 60_000,
72
+ initialDelayMs: 60_000,
73
+ idlePollMs: 10_000,
74
+ idleTimeoutMs: 2_500,
75
+ };
76
+
64
77
  function ensureDir(dir, mode = 0o700) {
65
78
  fs.mkdirSync(dir, { recursive: true, mode });
66
79
  try {
@@ -374,6 +387,304 @@ function appendServiceState(name, update) {
374
387
  updateRuntimeState({ services: next });
375
388
  }
376
389
 
390
+ function numberFromEnv(name, fallback, minimum = 0) {
391
+ const value = Number(process.env[name]);
392
+ if (!Number.isFinite(value) || value <= 0) return fallback;
393
+ return Math.max(minimum, value);
394
+ }
395
+
396
+ function sleep(ms) {
397
+ return new Promise((resolve) => {
398
+ const timer = setTimeout(resolve, ms);
399
+ if (typeof timer.unref === 'function') timer.unref();
400
+ });
401
+ }
402
+
403
+ function writeAutoUpdateState(update) {
404
+ const current = readJson(AUTO_UPDATE_STATE_FILE, {});
405
+ const next = {
406
+ ...current,
407
+ ...update,
408
+ updated_at: new Date().toISOString(),
409
+ };
410
+ writeJsonSecret(AUTO_UPDATE_STATE_FILE, next);
411
+ updateRuntimeState({
412
+ auto_update: {
413
+ status: next.status,
414
+ current_version: next.current_version || PACKAGE_VERSION,
415
+ target_version: next.target_version || null,
416
+ tag: next.tag || null,
417
+ critical: !!next.critical,
418
+ service_managed: !!next.service_managed,
419
+ last_checked_at: next.last_checked_at || null,
420
+ last_error: next.last_error || null,
421
+ active_turns: next.active_turns ?? null,
422
+ mcp_active: next.mcp_active ?? null,
423
+ tunnel_active: next.tunnel_active ?? null,
424
+ },
425
+ });
426
+ return next;
427
+ }
428
+
429
+ function localJsonRequest(port, requestPath, runtimeToken, timeoutMs) {
430
+ return new Promise((resolve, reject) => {
431
+ const req = http.request(
432
+ {
433
+ host: '127.0.0.1',
434
+ port,
435
+ path: requestPath,
436
+ method: 'GET',
437
+ timeout: timeoutMs,
438
+ headers: runtimeToken ? { 'x-amalgm-runtime-token': runtimeToken } : {},
439
+ },
440
+ (res) => {
441
+ const chunks = [];
442
+ res.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
443
+ res.on('end', () => {
444
+ const body = Buffer.concat(chunks).toString('utf8');
445
+ if (res.statusCode < 200 || res.statusCode >= 300) {
446
+ reject(new Error(`HTTP ${res.statusCode}: ${body}`));
447
+ return;
448
+ }
449
+ try {
450
+ resolve(body ? JSON.parse(body) : {});
451
+ } catch (error) {
452
+ reject(error);
453
+ }
454
+ });
455
+ },
456
+ );
457
+ req.on('timeout', () => {
458
+ req.destroy(new Error('request timed out'));
459
+ });
460
+ req.on('error', reject);
461
+ req.end();
462
+ });
463
+ }
464
+
465
+ async function runtimeIdleStatus({ ports, runtimeToken, tunnels }) {
466
+ const tunnelActive = tunnels.reduce((sum, tunnel) => (
467
+ sum + (typeof tunnel.activeCount === 'function' ? Number(tunnel.activeCount()) || 0 : 0)
468
+ ), 0);
469
+ const timeoutMs = numberFromEnv('AMALGM_UPDATE_IDLE_TIMEOUT_MS', AUTO_UPDATE_POLICY.idleTimeoutMs, 500);
470
+ const [chat, mcp] = await Promise.all([
471
+ localJsonRequest(ports.chatServer, '/runtime-idle', runtimeToken, timeoutMs),
472
+ localJsonRequest(ports.amalgmMcp, '/healthz', runtimeToken, timeoutMs),
473
+ ]);
474
+ const activeTurns = Number(chat?.active_turns) || 0;
475
+ const mcpActive = Number(mcp?.activeAgentConversations) || 0;
476
+ return {
477
+ idle: activeTurns === 0 && mcpActive === 0 && tunnelActive === 0,
478
+ active_turns: activeTurns,
479
+ mcp_active: mcpActive,
480
+ tunnel_active: tunnelActive,
481
+ };
482
+ }
483
+
484
+ function envFlag(name) {
485
+ const value = String(process.env[name] || '').trim().toLowerCase();
486
+ return ['1', 'true', 'yes', 'on'].includes(value);
487
+ }
488
+
489
+ function isServiceManaged() {
490
+ return envFlag('AMALGM_SERVICE_MANAGED');
491
+ }
492
+
493
+ function isCriticalUpdate(check) {
494
+ if (check?.critical === true || check?.required === true) return true;
495
+ return envFlag('AMALGM_UPDATE_CRITICAL') || envFlag('AMALGM_AUTO_UPDATE_CRITICAL');
496
+ }
497
+
498
+ function createAutoUpdateManager({ ports, runtimeToken, tunnels, foreground, requestRestart }) {
499
+ let stopped = false;
500
+ let timer = null;
501
+ let inFlight = false;
502
+ let installedUpdate = false;
503
+ const checkIntervalMs = numberFromEnv(
504
+ 'AMALGM_UPDATE_CHECK_INTERVAL_MS',
505
+ AUTO_UPDATE_POLICY.checkIntervalMs,
506
+ 60_000,
507
+ );
508
+ const initialDelayMs = numberFromEnv(
509
+ 'AMALGM_UPDATE_INITIAL_DELAY_MS',
510
+ AUTO_UPDATE_POLICY.initialDelayMs,
511
+ 5_000,
512
+ );
513
+ const idlePollMs = numberFromEnv(
514
+ 'AMALGM_UPDATE_IDLE_POLL_MS',
515
+ AUTO_UPDATE_POLICY.idlePollMs,
516
+ 1_000,
517
+ );
518
+ const serviceManaged = isServiceManaged();
519
+
520
+ function log(message, stream = 'stdout') {
521
+ const line = `[auto-update] ${message}`;
522
+ if (foreground) {
523
+ if (stream === 'stderr') console.error(line);
524
+ else console.log(line);
525
+ }
526
+ }
527
+
528
+ function schedule(delayMs) {
529
+ if (stopped || installedUpdate || timer) return;
530
+ timer = setTimeout(() => {
531
+ timer = null;
532
+ void checkAndUpdate();
533
+ }, delayMs);
534
+ if (typeof timer.unref === 'function') timer.unref();
535
+ }
536
+
537
+ async function waitForIdleAndRestart(check) {
538
+ const critical = isCriticalUpdate(check);
539
+ if (!serviceManaged) {
540
+ writeAutoUpdateState({
541
+ status: critical ? 'critical_restart_required' : 'restart_required',
542
+ current_version: PACKAGE_VERSION,
543
+ target_version: check.targetVersion,
544
+ tag: check.tag,
545
+ critical,
546
+ service_managed: false,
547
+ idle_blocked_reason: 'runtime is not service-managed',
548
+ });
549
+ log(`updated to ${check.targetVersion}; restart required because this runtime is not service-managed`);
550
+ return;
551
+ }
552
+
553
+ if (critical) {
554
+ writeAutoUpdateState({
555
+ status: 'critical_restart_pending',
556
+ current_version: PACKAGE_VERSION,
557
+ target_version: check.targetVersion,
558
+ tag: check.tag,
559
+ critical: true,
560
+ service_managed: true,
561
+ idle_blocked_reason: null,
562
+ });
563
+ log(`critical update installed; restarting into ${check.targetVersion}`);
564
+ requestRestart('critical_auto_update');
565
+ return;
566
+ }
567
+
568
+ while (!stopped) {
569
+ let idle;
570
+ try {
571
+ idle = await runtimeIdleStatus({ ports, runtimeToken, tunnels });
572
+ } catch (error) {
573
+ idle = {
574
+ idle: false,
575
+ active_turns: null,
576
+ mcp_active: null,
577
+ tunnel_active: null,
578
+ reason: error.message,
579
+ };
580
+ }
581
+
582
+ writeAutoUpdateState({
583
+ status: 'restart_pending',
584
+ current_version: PACKAGE_VERSION,
585
+ target_version: check.targetVersion,
586
+ tag: check.tag,
587
+ critical: false,
588
+ service_managed: true,
589
+ active_turns: idle.active_turns,
590
+ mcp_active: idle.mcp_active,
591
+ tunnel_active: idle.tunnel_active,
592
+ idle_blocked_reason: idle.idle ? null : idle.reason || 'runtime is busy',
593
+ });
594
+
595
+ if (idle.idle) {
596
+ log(`restarting into ${check.targetVersion}`);
597
+ requestRestart('auto_update');
598
+ return;
599
+ }
600
+ await sleep(idlePollMs);
601
+ }
602
+ }
603
+
604
+ async function checkAndUpdate() {
605
+ if (stopped || inFlight || installedUpdate) return;
606
+ inFlight = true;
607
+ const checkedAt = new Date().toISOString();
608
+ try {
609
+ writeAutoUpdateState({
610
+ status: 'checking',
611
+ current_version: PACKAGE_VERSION,
612
+ target_version: null,
613
+ tag: null,
614
+ service_managed: serviceManaged,
615
+ last_checked_at: checkedAt,
616
+ last_error: null,
617
+ });
618
+ const check = await checkForUpdate({ currentVersion: PACKAGE_VERSION });
619
+ if (!check.updateAvailable) {
620
+ writeAutoUpdateState({
621
+ status: 'current',
622
+ current_version: PACKAGE_VERSION,
623
+ target_version: check.targetVersion,
624
+ tag: check.tag,
625
+ service_managed: serviceManaged,
626
+ last_checked_at: checkedAt,
627
+ last_error: null,
628
+ });
629
+ return;
630
+ }
631
+
632
+ log(`installing ${check.targetVersion} from ${check.tag}`);
633
+ writeAutoUpdateState({
634
+ status: 'installing',
635
+ current_version: PACKAGE_VERSION,
636
+ target_version: check.targetVersion,
637
+ tag: check.tag,
638
+ critical: isCriticalUpdate(check),
639
+ service_managed: serviceManaged,
640
+ last_checked_at: checkedAt,
641
+ last_error: null,
642
+ });
643
+ await updatePackage({
644
+ check,
645
+ onOutput: (text, stream) => {
646
+ const trimmed = String(text || '').trim();
647
+ if (trimmed) log(trimmed, stream);
648
+ },
649
+ });
650
+ installedUpdate = true;
651
+ await waitForIdleAndRestart(check);
652
+ } catch (error) {
653
+ writeAutoUpdateState({
654
+ status: 'error',
655
+ current_version: PACKAGE_VERSION,
656
+ service_managed: serviceManaged,
657
+ last_checked_at: checkedAt,
658
+ last_error: error.message,
659
+ });
660
+ log(`skipped: ${error.message}`, 'stderr');
661
+ } finally {
662
+ inFlight = false;
663
+ if (!stopped && !installedUpdate) schedule(checkIntervalMs);
664
+ }
665
+ }
666
+
667
+ return {
668
+ start() {
669
+ if (!shouldAutoUpdate()) {
670
+ writeAutoUpdateState({
671
+ status: 'disabled',
672
+ current_version: PACKAGE_VERSION,
673
+ service_managed: serviceManaged,
674
+ last_error: null,
675
+ });
676
+ return;
677
+ }
678
+ schedule(initialDelayMs);
679
+ },
680
+ stop() {
681
+ stopped = true;
682
+ if (timer) clearTimeout(timer);
683
+ timer = null;
684
+ },
685
+ };
686
+ }
687
+
377
688
  function checkServiceHealth(spec, timeoutMs = CHILD_HEALTH_POLICY.timeoutMs) {
378
689
  if (!spec.port) return Promise.resolve(true);
379
690
  return new Promise((resolve) => {
@@ -675,9 +986,11 @@ async function startSupervisor(options = {}) {
675
986
 
676
987
  await new Promise((resolve) => {
677
988
  let cleaned = false;
989
+ let autoUpdateManager = null;
678
990
  const cleanup = (signal) => {
679
991
  if (cleaned) return;
680
992
  cleaned = true;
993
+ if (autoUpdateManager) autoUpdateManager.stop();
681
994
  writeShutdownReason(signal);
682
995
  for (const tunnel of tunnels) tunnel.stop();
683
996
  for (const proc of managed) proc.stop();
@@ -696,6 +1009,15 @@ async function startSupervisor(options = {}) {
696
1009
  resolve();
697
1010
  };
698
1011
 
1012
+ autoUpdateManager = createAutoUpdateManager({
1013
+ ports,
1014
+ runtimeToken,
1015
+ tunnels,
1016
+ foreground: options.foreground,
1017
+ requestRestart: cleanup,
1018
+ });
1019
+ autoUpdateManager.start();
1020
+
699
1021
  process.once('SIGINT', () => cleanup('SIGINT'));
700
1022
  process.once('SIGTERM', () => cleanup('SIGTERM'));
701
1023
  process.once('SIGHUP', () => cleanup('SIGHUP'));
@@ -113,6 +113,7 @@ function createChatTunnel({ record, foreground = false }) {
113
113
  let backoffMs = 1000;
114
114
  let connectStartedAt = 0;
115
115
  let lastGatewayFrameAt = 0;
116
+ const upstreamStreams = new Map();
116
117
 
117
118
  function log(message) {
118
119
  if (foreground) console.log(`[chat-tunnel] ${message}`);
@@ -245,12 +246,19 @@ function createChatTunnel({ record, foreground = false }) {
245
246
  });
246
247
  });
247
248
  res.on('end', () => {
249
+ upstreamStreams.delete(frame.req_id);
248
250
  send({ type: 'stream_res_end', req_id: frame.req_id });
249
251
  });
252
+ res.on('close', () => {
253
+ upstreamStreams.delete(frame.req_id);
254
+ });
250
255
  },
251
256
  );
252
257
 
258
+ upstreamStreams.set(frame.req_id, req);
259
+
253
260
  req.on('error', (error) => {
261
+ upstreamStreams.delete(frame.req_id);
254
262
  send({
255
263
  type: 'stream_res_error',
256
264
  req_id: frame.req_id,
@@ -320,6 +328,14 @@ function createChatTunnel({ record, foreground = false }) {
320
328
  stopped = true;
321
329
  if (reconnectTimer) clearTimeout(reconnectTimer);
322
330
  stopWatchdog();
331
+ for (const stream of upstreamStreams.values()) {
332
+ try {
333
+ stream.destroy(new Error('chat tunnel stopped'));
334
+ } catch {
335
+ // noop
336
+ }
337
+ }
338
+ upstreamStreams.clear();
323
339
  if (ws) {
324
340
  try {
325
341
  ws.close();
@@ -328,6 +344,9 @@ function createChatTunnel({ record, foreground = false }) {
328
344
  }
329
345
  }
330
346
  },
347
+ activeCount() {
348
+ return upstreamStreams.size;
349
+ },
331
350
  };
332
351
  }
333
352
 
@@ -425,6 +425,9 @@ function createEventTunnel({ record, foreground = false }) {
425
425
  upstreamStreams.delete(frame.req_id);
426
426
  send({ type: 'stream_res_end', req_id: frame.req_id });
427
427
  });
428
+ res.on('close', () => {
429
+ upstreamStreams.delete(frame.req_id);
430
+ });
428
431
  },
429
432
  );
430
433
 
@@ -609,6 +612,9 @@ function createEventTunnel({ record, foreground = false }) {
609
612
  }
610
613
  }
611
614
  },
615
+ activeCount() {
616
+ return upstreamStreams.size + upstreamSockets.size;
617
+ },
612
618
  };
613
619
  }
614
620
 
package/lib/updater.js ADDED
@@ -0,0 +1,205 @@
1
+ 'use strict';
2
+
3
+ const { spawn } = require('child_process');
4
+ const {
5
+ AMALGM_RUNTIME_LABEL,
6
+ } = require('./paths');
7
+ const {
8
+ npmTagForRuntimeLabel,
9
+ } = require('./runtime-identity');
10
+
11
+ const PACKAGE_VERSION = require('../package.json').version;
12
+ const DEFAULT_VIEW_TIMEOUT_MS = 15_000;
13
+ const DEFAULT_INSTALL_TIMEOUT_MS = 10 * 60_000;
14
+
15
+ function parseSemver(value) {
16
+ const raw = String(value || '').trim().replace(/^v/, '');
17
+ const [core, prerelease = ''] = raw.split('-', 2);
18
+ const [major = '0', minor = '0', patch = '0'] = core.split('.');
19
+ return {
20
+ major: Number(major) || 0,
21
+ minor: Number(minor) || 0,
22
+ patch: Number(patch) || 0,
23
+ prerelease,
24
+ };
25
+ }
26
+
27
+ function comparePrerelease(left, right) {
28
+ if (!left && !right) return 0;
29
+ if (!left) return 1;
30
+ if (!right) return -1;
31
+
32
+ const aParts = left.split('.');
33
+ const bParts = right.split('.');
34
+ const count = Math.max(aParts.length, bParts.length);
35
+ for (let i = 0; i < count; i += 1) {
36
+ const a = aParts[i] || '';
37
+ const b = bParts[i] || '';
38
+ if (a === b) continue;
39
+ if (!a) return -1;
40
+ if (!b) return 1;
41
+ const aNum = /^\d+$/.test(a) ? Number(a) : null;
42
+ const bNum = /^\d+$/.test(b) ? Number(b) : null;
43
+ if (aNum !== null && bNum !== null) return aNum > bNum ? 1 : -1;
44
+ if (aNum !== null && bNum === null) return -1;
45
+ if (aNum === null && bNum !== null) return 1;
46
+ if (a !== b) return a > b ? 1 : -1;
47
+ }
48
+ return 0;
49
+ }
50
+
51
+ function compareVersions(left, right) {
52
+ const a = parseSemver(left);
53
+ const b = parseSemver(right);
54
+ for (const key of ['major', 'minor', 'patch']) {
55
+ if (a[key] !== b[key]) return a[key] > b[key] ? 1 : -1;
56
+ }
57
+ return comparePrerelease(a.prerelease, b.prerelease);
58
+ }
59
+
60
+ function npmCommand() {
61
+ return process.platform === 'win32' ? 'npm.cmd' : 'npm';
62
+ }
63
+
64
+ function npmInstallTag(options = {}) {
65
+ const explicit = options.tag || options.channel;
66
+ if (explicit && explicit !== true) return String(explicit).replace(/^@/, '');
67
+ return npmTagForRuntimeLabel(options.runtimeLabel || AMALGM_RUNTIME_LABEL);
68
+ }
69
+
70
+ function npmEnv(env = process.env) {
71
+ return {
72
+ ...env,
73
+ npm_config_audit: 'false',
74
+ npm_config_fund: 'false',
75
+ };
76
+ }
77
+
78
+ function runNpm(args, options = {}) {
79
+ return new Promise((resolve, reject) => {
80
+ const child = spawn(npmCommand(), args, {
81
+ env: npmEnv(options.env),
82
+ windowsHide: true,
83
+ stdio: ['ignore', 'pipe', 'pipe'],
84
+ });
85
+ let stdout = '';
86
+ let stderr = '';
87
+ let timedOut = false;
88
+ const onOutput = typeof options.onOutput === 'function' ? options.onOutput : null;
89
+ const timeoutMs = Number(options.timeoutMs || 0);
90
+ const timer = timeoutMs > 0 ? setTimeout(() => {
91
+ timedOut = true;
92
+ try {
93
+ child.kill('SIGTERM');
94
+ } catch {
95
+ // noop
96
+ }
97
+ }, timeoutMs) : null;
98
+ if (timer && typeof timer.unref === 'function') timer.unref();
99
+
100
+ child.stdout.on('data', (chunk) => {
101
+ const text = chunk.toString();
102
+ stdout += text;
103
+ if (onOutput) onOutput(text, 'stdout');
104
+ });
105
+ child.stderr.on('data', (chunk) => {
106
+ const text = chunk.toString();
107
+ stderr += text;
108
+ if (onOutput) onOutput(text, 'stderr');
109
+ });
110
+ child.once('error', (error) => {
111
+ if (timer) clearTimeout(timer);
112
+ reject(error);
113
+ });
114
+ child.once('close', (status, signal) => {
115
+ if (timer) clearTimeout(timer);
116
+ resolve({ status, signal, stdout, stderr, timedOut });
117
+ });
118
+ });
119
+ }
120
+
121
+ function npmFailureDetail(result) {
122
+ if (result.timedOut) return 'npm command timed out';
123
+ return String(result.stderr || result.stdout || '').trim();
124
+ }
125
+
126
+ async function npmPackageVersionForTag(tag, options = {}) {
127
+ const result = await runNpm(['view', `amalgm@${tag}`, 'version', '--json'], {
128
+ timeoutMs: options.timeoutMs || DEFAULT_VIEW_TIMEOUT_MS,
129
+ env: options.env,
130
+ });
131
+ if (result.status !== 0) {
132
+ const detail = npmFailureDetail(result);
133
+ throw new Error(`Could not check npm dist-tag "${tag}". ${detail}`);
134
+ }
135
+
136
+ const raw = String(result.stdout || '').trim();
137
+ try {
138
+ return JSON.parse(raw);
139
+ } catch {
140
+ return raw.replace(/^"|"$/g, '');
141
+ }
142
+ }
143
+
144
+ async function installNpmPackageTag(tag, options = {}) {
145
+ const result = await runNpm(['install', '-g', `amalgm@${tag}`], {
146
+ timeoutMs: options.timeoutMs || DEFAULT_INSTALL_TIMEOUT_MS,
147
+ env: options.env,
148
+ onOutput: options.onOutput,
149
+ });
150
+ if (result.status !== 0) {
151
+ const detail = npmFailureDetail(result);
152
+ const status = result.status ?? result.signal ?? 'unknown';
153
+ throw new Error(`npm install -g amalgm@${tag} failed with status ${status}${detail ? `: ${detail}` : ''}`);
154
+ }
155
+ return result;
156
+ }
157
+
158
+ async function checkForUpdate(options = {}) {
159
+ const tag = npmInstallTag(options);
160
+ const currentVersion = options.currentVersion || PACKAGE_VERSION;
161
+ const targetVersion = await npmPackageVersionForTag(tag, options);
162
+ if (!targetVersion) throw new Error(`npm did not return a version for amalgm@${tag}`);
163
+ return {
164
+ tag,
165
+ currentVersion,
166
+ targetVersion,
167
+ updateAvailable: compareVersions(targetVersion, currentVersion) > 0,
168
+ };
169
+ }
170
+
171
+ async function updatePackage(options = {}) {
172
+ const check = options.check || await checkForUpdate(options);
173
+ if (!check.updateAvailable) return { ...check, updated: false };
174
+ await installNpmPackageTag(check.tag, options);
175
+ return { ...check, updated: true };
176
+ }
177
+
178
+ function envDisablesAutoUpdate(env = process.env) {
179
+ const disable = String(env.AMALGM_DISABLE_AUTO_UPDATE || '').trim().toLowerCase();
180
+ if (['1', 'true', 'yes', 'on'].includes(disable)) return true;
181
+ const auto = String(env.AMALGM_AUTO_UPDATE || '').trim().toLowerCase();
182
+ return ['0', 'false', 'no', 'off'].includes(auto);
183
+ }
184
+
185
+ function autoUpdateSupported(options = {}) {
186
+ const label = options.runtimeLabel || AMALGM_RUNTIME_LABEL;
187
+ return label !== 'local';
188
+ }
189
+
190
+ function shouldAutoUpdate(options = {}) {
191
+ const env = options.env || process.env;
192
+ return autoUpdateSupported(options) && !envDisablesAutoUpdate(env);
193
+ }
194
+
195
+ module.exports = {
196
+ checkForUpdate,
197
+ compareVersions,
198
+ envDisablesAutoUpdate,
199
+ installNpmPackageTag,
200
+ npmInstallTag,
201
+ npmPackageVersionForTag,
202
+ parseSemver,
203
+ shouldAutoUpdate,
204
+ updatePackage,
205
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amalgm",
3
- "version": "0.1.78",
3
+ "version": "0.1.79",
4
4
  "description": "Amalgm local computer runtime: login, MCP, chat, events, previews, and tunnels.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -17,7 +17,7 @@
17
17
  "sync-runtime": "node ../../scripts/sync-npm-package-runtime.mjs",
18
18
  "prepack": "node ../../scripts/sync-npm-package-runtime.mjs",
19
19
  "pack:dry": "npm pack --dry-run",
20
- "check": "node --check bin/amalgm.js && node --check lib/auth-store.js && node --check lib/cli.js && node --check lib/paths.js && node --check lib/process-cleanup.js && node --check lib/runtime-identity.js && node --check lib/runtime-manifest.js && node --check lib/service.js && node --check lib/state-migration.js && node --check lib/supervisor.js && node --check lib/tunnel-chat.js && node --check lib/tunnel-events.js && node --check runtime/lib/runtime-manifest.js && node --check runtime/scripts/runtime-auth.js && node --check runtime/scripts/proxy-token-store.js && node --check runtime/scripts/local-gateway.js && node --check runtime/scripts/port-monitor.js && node --check runtime/scripts/chat-server.js && node --check runtime/scripts/chat-server/index.js && node --check runtime/scripts/chat-server/config.js && node --check runtime/scripts/chat-core/tooling/native-binaries.js && node --check runtime/scripts/chat-core/tooling/package-import.js && node --check runtime/scripts/chat-core/tooling/runtime-home.js && node --check runtime/scripts/amalgm-mcp/index.js && node --check runtime/scripts/amalgm-mcp/config.js && node --check runtime/scripts/lib/project-paths.js && node --check runtime/scripts/lib/runtime-paths.js"
20
+ "check": "node --check bin/amalgm.js && node --check lib/auth-store.js && node --check lib/cli.js && node --check lib/paths.js && node --check lib/process-cleanup.js && node --check lib/runtime-identity.js && node --check lib/runtime-manifest.js && node --check lib/service.js && node --check lib/state-migration.js && node --check lib/supervisor.js && node --check lib/tunnel-chat.js && node --check lib/tunnel-events.js && node --check lib/updater.js && node --check runtime/lib/runtime-manifest.js && node --check runtime/scripts/runtime-auth.js && node --check runtime/scripts/proxy-token-store.js && node --check runtime/scripts/local-gateway.js && node --check runtime/scripts/port-monitor.js && node --check runtime/scripts/chat-server.js && node --check runtime/scripts/chat-server/index.js && node --check runtime/scripts/chat-server/config.js && node --check runtime/scripts/chat-core/tooling/native-binaries.js && node --check runtime/scripts/chat-core/tooling/package-import.js && node --check runtime/scripts/chat-core/tooling/runtime-home.js && node --check runtime/scripts/amalgm-mcp/index.js && node --check runtime/scripts/amalgm-mcp/config.js && node --check runtime/scripts/lib/project-paths.js && node --check runtime/scripts/lib/runtime-paths.js"
21
21
  },
22
22
  "engines": {
23
23
  "node": ">=20"
@@ -50,6 +50,7 @@ class ClaudeAdapter {
50
50
  mcpServers: toClaudeMcpServers(contract),
51
51
  permissionMode: 'bypassPermissions',
52
52
  allowDangerouslySkipPermissions: true,
53
+ includePartialMessages: true,
53
54
  ...(process.env.CHAT_CORE_DEBUG_CLAUDE === '1'
54
55
  ? { debug: true, debugFile: path.join(runtime.runtimeHome, 'claude-debug.log') }
55
56
  : {}),
@@ -95,6 +95,14 @@ class ChatCore {
95
95
  };
96
96
  }
97
97
 
98
+ idleStatus() {
99
+ const activeTurns = typeof this.turns.activeCount === 'function' ? this.turns.activeCount() : 0;
100
+ return {
101
+ idle: activeTurns === 0,
102
+ active_turns: activeTurns,
103
+ };
104
+ }
105
+
98
106
  async finalizeActiveTurn(sessionId) {
99
107
  const entry = this.turns.active(sessionId);
100
108
  if (!entry) return false;
@@ -133,6 +133,7 @@ function ensureClaudeTextState(state) {
133
133
  if (!state) return null;
134
134
  if (!state.claudeTextByIndex) state.claudeTextByIndex = new Map();
135
135
  if (!state.claudeReasoningByIndex) state.claudeReasoningByIndex = new Map();
136
+ if (!state.claudeBlockIndexByType) state.claudeBlockIndexByType = new Map();
136
137
  return state;
137
138
  }
138
139
 
@@ -141,10 +142,34 @@ function blockIndex(value) {
141
142
  return Number.isInteger(index) && index >= 0 ? index : null;
142
143
  }
143
144
 
144
- function emitIndexedTextDelta(state, index, kind, delta, providerSessionId, raw) {
145
- if (!delta) return [];
145
+ function indexedTextKey(messageKey, index) {
146
+ const key = blockIndex(index);
147
+ if (key === null) return null;
148
+ return `${messageKey || 'current'}:${key}`;
149
+ }
150
+
151
+ function snapshotBlockType(block) {
152
+ if (block?.type === 'thinking' || block?.type === 'redacted_thinking') return 'reasoning';
153
+ return String(block?.type || 'unknown');
154
+ }
155
+
156
+ function rememberSnapshotBlockIndex(state, messageKey, block, index) {
146
157
  const textState = ensureClaudeTextState(state);
147
158
  const key = blockIndex(index);
159
+ if (!textState || key === null) return;
160
+ textState.claudeBlockIndexByType.set(`${messageKey || 'current'}:${snapshotBlockType(block)}`, key);
161
+ }
162
+
163
+ function snapshotBlockIndex(state, messageKey, block, fallback) {
164
+ const textState = ensureClaudeTextState(state);
165
+ const known = textState?.claudeBlockIndexByType?.get(`${messageKey || 'current'}:${snapshotBlockType(block)}`);
166
+ return known ?? fallback;
167
+ }
168
+
169
+ function emitIndexedTextDelta(state, messageKey, index, kind, delta, providerSessionId, raw) {
170
+ if (!delta) return [];
171
+ const textState = ensureClaudeTextState(state);
172
+ const key = indexedTextKey(messageKey, index);
148
173
  if (textState && key !== null) {
149
174
  const map = kind === 'reasoning' ? textState.claudeReasoningByIndex : textState.claudeTextByIndex;
150
175
  map.set(key, `${map.get(key) || ''}${delta}`);
@@ -159,11 +184,13 @@ function emitIndexedTextSnapshot(block, field, kind, providerSessionId, state, r
159
184
  if (!text) return [];
160
185
  let delta = text;
161
186
  const textState = ensureClaudeTextState(state);
162
- const key = blockIndex(block.index);
187
+ const key = indexedTextKey(block.messageKey, block.index);
163
188
  if (textState && key !== null) {
164
189
  const map = kind === 'reasoning' ? textState.claudeReasoningByIndex : textState.claudeTextByIndex;
165
190
  const streamed = map.get(key) || '';
166
- delta = streamed ? (text.startsWith(streamed) ? text.slice(streamed.length) : '') : text;
191
+ delta = streamed
192
+ ? (text === streamed ? '' : (text.startsWith(streamed) ? text.slice(streamed.length) : text))
193
+ : text;
167
194
  map.set(key, text);
168
195
  }
169
196
  if (!delta) return [];
@@ -354,18 +381,26 @@ function normalizeClaudeStreamEvent(message, providerSessionId, state) {
354
381
  if (!event || typeof event !== 'object') return [];
355
382
  const raw = eventRaw('claude.sdk.message', `claude/${event.type || 'unknown'}`, message);
356
383
 
384
+ if (event.type === 'message_start' && event.message?.id) {
385
+ if (state) state.claudeCurrentMessageKey = event.message.id;
386
+ return [];
387
+ }
388
+
389
+ const messageKey = state?.claudeCurrentMessageKey || message?.message?.id || message?.uuid || null;
390
+
357
391
  if (event.type === 'content_block_start') {
358
392
  const block = event.content_block || event.block;
393
+ rememberSnapshotBlockIndex(state, messageKey, block, event.index);
359
394
  if (!block || !TOOL_USE_BLOCK_TYPES.has(block.type)) return [];
360
395
  return startOrUpdateClaudeTool({ ...block, index: event.index }, providerSessionId, state, raw);
361
396
  }
362
397
 
363
398
  if (event.type === 'content_block_delta' && event.delta?.type === 'text_delta') {
364
- return emitIndexedTextDelta(state, event.index, 'text', event.delta.text, providerSessionId, raw);
399
+ return emitIndexedTextDelta(state, messageKey, event.index, 'text', event.delta.text, providerSessionId, raw);
365
400
  }
366
401
 
367
402
  if (event.type === 'content_block_delta' && event.delta?.type === 'thinking_delta') {
368
- return emitIndexedTextDelta(state, event.index, 'reasoning', event.delta.thinking, providerSessionId, raw);
403
+ return emitIndexedTextDelta(state, messageKey, event.index, 'reasoning', event.delta.thinking, providerSessionId, raw);
369
404
  }
370
405
 
371
406
  if (event.type === 'content_block_delta' && event.delta?.type === 'input_json_delta') {
@@ -429,9 +464,11 @@ function normalizeClaudeMessage(message, state) {
429
464
 
430
465
  if (message.type === 'assistant') {
431
466
  const events = [];
467
+ const messageKey = message.message?.id || message.uuid || state?.claudeCurrentMessageKey || null;
432
468
  for (const [index, block] of (message.message?.content || []).entries()) {
469
+ const blockSnapshotIndex = block?.index ?? snapshotBlockIndex(state, messageKey, block, index);
433
470
  events.push(...normalizeClaudeContentBlock(
434
- { ...block, index },
471
+ { ...block, index: blockSnapshotIndex, messageKey },
435
472
  providerSessionId,
436
473
  state,
437
474
  eventRaw('claude.sdk.message', 'claude/assistant/content_block', message),
@@ -38,6 +38,7 @@ function parseRoute(url) {
38
38
  if (parts[0] === 'chat' && parts.length === 1) return { handler: 'chat' };
39
39
  if (parts[0] === 'raw-temp' && parts[1]) return { handler: 'raw-temp', id: parts[1], afterIndex: Number(u.searchParams.get('afterIndex') ?? -1) };
40
40
  if (parts[0] === 'active' && parts[1]) return { handler: 'active', id: parts[1] };
41
+ if (parts[0] === 'runtime-idle') return { handler: 'runtime-idle' };
41
42
  if (parts[0] === 'stop' && parts[1]) return { handler: 'stop', id: parts[1] };
42
43
  if (parts[0] === 'destroy' && parts[1]) return { handler: 'destroy', id: parts[1] };
43
44
  if (parts[0] === 'egress' && parts[1]) {
@@ -72,6 +73,7 @@ function createServer(core = createCore()) {
72
73
  try {
73
74
  if (route.handler === 'healthz') return sendJson(res, 200, { status: 'ok', runtime: 'chat-core', uptime: process.uptime() });
74
75
  if (route.handler === 'active') return sendJson(res, 200, core.active(route.id));
76
+ if (route.handler === 'runtime-idle') return sendJson(res, 200, core.idleStatus());
75
77
  if (route.handler === 'stop') {
76
78
  if (req.method !== 'POST') return sendJson(res, 405, { error: 'Method not allowed' });
77
79
  return sendJson(res, 200, { stopped: await core.stop(route.id), codeSessionId: route.id });
@@ -33,6 +33,15 @@ class TurnStore {
33
33
  return turnId ? this.turns.get(turnId) || null : null;
34
34
  }
35
35
 
36
+ activeCount() {
37
+ let count = 0;
38
+ for (const turnId of this.activeBySession.values()) {
39
+ const entry = this.turns.get(turnId);
40
+ if (entry && ['streaming', 'cancelling'].includes(entry.status)) count += 1;
41
+ }
42
+ return count;
43
+ }
44
+
36
45
  latest(sessionId) {
37
46
  return this.active(sessionId) || this.get(this.latestBySession.get(sessionId));
38
47
  }
@@ -54,3 +54,91 @@ test('claude thinking stream deltas emit reasoning before final snapshot without
54
54
  }, state);
55
55
  assert.deepEqual(finalSnapshot, []);
56
56
  });
57
+
58
+ test('claude partial stream events emit text deltas', () => {
59
+ const state = { providerSessionId: 'claude-session' };
60
+
61
+ normalizeClaudeMessage({
62
+ type: 'stream_event',
63
+ event: {
64
+ type: 'message_start',
65
+ message: { id: 'msg_streaming' },
66
+ },
67
+ }, state);
68
+
69
+ normalizeClaudeMessage({
70
+ type: 'stream_event',
71
+ event: {
72
+ type: 'content_block_start',
73
+ index: 1,
74
+ content_block: { type: 'text', text: '' },
75
+ },
76
+ }, state);
77
+
78
+ const delta = normalizeClaudeMessage({
79
+ type: 'stream_event',
80
+ event: {
81
+ type: 'content_block_delta',
82
+ index: 1,
83
+ delta: { type: 'text_delta', text: 'streamed' },
84
+ },
85
+ }, state);
86
+
87
+ assert.equal(delta.length, 1);
88
+ assert.equal(delta[0].type, 'text.delta');
89
+ assert.equal(delta[0].text, 'streamed');
90
+
91
+ const finalSnapshot = normalizeClaudeMessage({
92
+ type: 'assistant',
93
+ message: {
94
+ id: 'msg_streaming',
95
+ content: [{ type: 'text', text: 'streamed' }],
96
+ },
97
+ }, state);
98
+ assert.deepEqual(finalSnapshot, []);
99
+ });
100
+
101
+ test('claude assistant snapshots with reused block indexes after tools are not dropped', () => {
102
+ const state = { providerSessionId: 'claude-session' };
103
+
104
+ const intro = normalizeClaudeMessage({
105
+ type: 'assistant',
106
+ message: {
107
+ id: 'msg_intro',
108
+ content: [{ type: 'text', text: 'Let me search first.' }],
109
+ },
110
+ }, state);
111
+ assert.equal(intro.length, 1);
112
+ assert.equal(intro[0].type, 'text.delta');
113
+ assert.equal(intro[0].text, 'Let me search first.');
114
+
115
+ const tool = normalizeClaudeMessage({
116
+ type: 'assistant',
117
+ message: {
118
+ id: 'msg_tool',
119
+ content: [{ type: 'tool_use', id: 'toolu_1', name: 'WebSearch', input: { query: 'news' } }],
120
+ },
121
+ }, state);
122
+ assert.equal(tool.length, 1);
123
+ assert.equal(tool[0].type, 'tool.started');
124
+
125
+ const result = normalizeClaudeMessage({
126
+ type: 'user',
127
+ message: {
128
+ content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'results' }],
129
+ },
130
+ }, state);
131
+ assert.equal(result.length, 1);
132
+ assert.equal(result[0].type, 'tool.completed');
133
+
134
+ const finalAnswer = normalizeClaudeMessage({
135
+ type: 'assistant',
136
+ message: {
137
+ id: 'msg_final',
138
+ content: [{ type: 'text', text: 'Here are the results.' }],
139
+ },
140
+ }, state);
141
+ assert.equal(finalAnswer.length, 1);
142
+ assert.equal(finalAnswer[0].type, 'text.delta');
143
+ assert.equal(finalAnswer[0].text, 'Here are the results.');
144
+ });
@@ -117,3 +117,43 @@ test('POST /chat streams from the same indexed raw chunks as /raw-temp', async (
117
117
  await new Promise((resolve) => server.close(resolve));
118
118
  }
119
119
  });
120
+
121
+ test('GET /runtime-idle reports active turn count', async () => {
122
+ process.env.AMALGM_RUNTIME_AUTH = 'disabled';
123
+
124
+ const core = new ChatCore({
125
+ db: {
126
+ mergeSessionMetadata: async () => {},
127
+ },
128
+ runtime: runtime(),
129
+ turns: new TurnStore(),
130
+ options: {
131
+ beginTurn: async () => {},
132
+ },
133
+ });
134
+ const server = createServer(core);
135
+ const baseUrl = await listen(server);
136
+
137
+ try {
138
+ let res = await fetch(`${baseUrl}/runtime-idle`);
139
+ assert.equal(res.status, 200);
140
+ assert.deepEqual(await res.json(), { idle: true, active_turns: 0 });
141
+
142
+ const entry = core.turns.start({
143
+ sessionId: 'session-test',
144
+ turnId: 'turn-test',
145
+ assistantMessageId: 'assistant-test',
146
+ userMessageId: 'user-test',
147
+ });
148
+ res = await fetch(`${baseUrl}/runtime-idle`);
149
+ assert.equal(res.status, 200);
150
+ assert.deepEqual(await res.json(), { idle: false, active_turns: 1 });
151
+
152
+ core.turns.mark(entry.turnId, 'complete');
153
+ res = await fetch(`${baseUrl}/runtime-idle`);
154
+ assert.equal(res.status, 200);
155
+ assert.deepEqual(await res.json(), { idle: true, active_turns: 0 });
156
+ } finally {
157
+ await new Promise((resolve) => server.close(resolve));
158
+ }
159
+ });