@north-light/crouter 0.3.216 → 0.3.218

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 (70) hide show
  1. package/dist/api/client.d.ts +5 -2
  2. package/dist/api/client.js +7 -1
  3. package/dist/api/dto/canvas.d.ts +9 -0
  4. package/dist/api/dto/nodes.d.ts +27 -1
  5. package/dist/builtin-memory/04-base-worker.md +7 -1
  6. package/dist/builtin-memory/internal/plugins.md +67 -0
  7. package/dist/builtin-memory/memory-read-orientation.md +13 -0
  8. package/dist/clients/attach/chrome/bash-jobs.js +1 -1
  9. package/dist/clients/attach/photon_rs_bg.wasm +0 -0
  10. package/dist/clients/attach/viewer.js +516 -516
  11. package/dist/clients/inbox/__tests__/serial/mount-panel.test.js +2 -2
  12. package/dist/clients/inbox/tui/input.js +12 -8
  13. package/dist/clients/inbox/tui/slots.d.ts +3 -1
  14. package/dist/clients/inbox/tui/slots.js +9 -3
  15. package/dist/commands/human/shared.js +1 -1
  16. package/dist/commands/memory/edit.js +6 -1
  17. package/dist/commands/memory/lint.d.ts +1 -6
  18. package/dist/commands/memory/lint.js +18 -126
  19. package/dist/commands/memory/list.d.ts +1 -0
  20. package/dist/commands/memory/list.js +17 -2
  21. package/dist/commands/memory/read.js +15 -6
  22. package/dist/commands/memory/shared.d.ts +33 -3
  23. package/dist/commands/memory/shared.js +180 -1
  24. package/dist/commands/memory/write.js +6 -1
  25. package/dist/commands/pkg/browse/catalog.js +2 -0
  26. package/dist/commands/pkg/browse/model.d.ts +4 -1
  27. package/dist/commands/pkg/plugin-manage.js +209 -148
  28. package/dist/core/__tests__/human-cancel-guard.test.js +30 -12
  29. package/dist/core/bash-jobs.d.ts +2 -5
  30. package/dist/core/bash-jobs.js +4 -8
  31. package/dist/core/command-plugins/bundle.d.ts +4 -1
  32. package/dist/core/command-plugins/bundle.js +16 -2
  33. package/dist/core/human/__tests__/page.test.js +12 -0
  34. package/dist/core/human/component-docs.js +6 -1
  35. package/dist/core/human/page-schema.d.ts +14 -0
  36. package/dist/core/human/page-schema.js +24 -7
  37. package/dist/core/human/page.d.ts +2 -0
  38. package/dist/core/human/page.js +17 -6
  39. package/dist/core/human/scan.d.ts +5 -4
  40. package/dist/core/human/scan.js +7 -4
  41. package/dist/core/human/tickets.js +9 -2
  42. package/dist/core/io.js +3 -2
  43. package/dist/core/manifest.d.ts +2 -0
  44. package/dist/core/manifest.js +5 -0
  45. package/dist/core/memory/extensions.d.ts +30 -0
  46. package/dist/core/memory/extensions.js +219 -0
  47. package/dist/core/preview-result-path.d.ts +4 -0
  48. package/dist/core/preview-result-path.js +25 -0
  49. package/dist/core/substrate/frontmatter-validation.d.ts +13 -0
  50. package/dist/core/substrate/frontmatter-validation.js +101 -0
  51. package/dist/core/substrate/index.d.ts +1 -0
  52. package/dist/core/substrate/index.js +1 -0
  53. package/dist/daemon/api/__tests__/nodes-activity-query.test.d.ts +1 -0
  54. package/dist/daemon/api/__tests__/nodes-activity-query.test.js +101 -0
  55. package/dist/daemon/api/handlers/canvas.js +3 -0
  56. package/dist/daemon/api/handlers/inbox.js +4 -3
  57. package/dist/daemon/api/handlers/nodes.d.ts +1 -0
  58. package/dist/daemon/api/handlers/nodes.js +101 -19
  59. package/dist/daemon/api/handlers/reports.d.ts +4 -0
  60. package/dist/daemon/api/handlers/reports.js +14 -8
  61. package/dist/daemon/crtrd.js +7 -5
  62. package/dist/daemon/manage.d.ts +3 -0
  63. package/dist/daemon/manage.js +14 -0
  64. package/dist/pi-extensions/canvas-bash-valve.d.ts +4 -3
  65. package/dist/pi-extensions/canvas-bash-valve.js +19 -6
  66. package/dist/pi-extensions/canvas-preview-result.d.ts +0 -8
  67. package/dist/pi-extensions/canvas-preview-result.js +9 -23
  68. package/dist/types.d.ts +31 -0
  69. package/package.json +1 -1
  70. package/runtime.lock.json +2 -2
@@ -1,4 +1,4 @@
1
- import { join, resolve, isAbsolute, sep } from 'node:path';
1
+ import { dirname, join, resolve, isAbsolute, relative, sep } from 'node:path';
2
2
  import { renameSync, writeFileSync } from 'node:fs';
3
3
  import { createHash } from 'node:crypto';
4
4
  import { defineLeaf } from '../../core/command.js';
@@ -7,9 +7,12 @@ import { findMarketplaceByName, findPluginByName, listAllPlugins } from '../../c
7
7
  import { pluginsDir, ensureProjectScopeRoot, userScopeRoot, resolveScopeArg, projectScopeRoot, requireScopeRoot, } from '../../core/scope.js';
8
8
  import { updateConfig, updateState, ensureScopeInitialized, invalidPluginKindsReasons, invalidPluginBinReasons, invalidPluginRequiresReasons, normalizeRequires } from '../../core/config.js';
9
9
  import { invalidPageComponentsReasons } from '../../core/human/page-catalog.js';
10
- import { pathExists, ensureDir, removePath, nowIso, linkOrCopy, isSymlink, atomicWriteJson } from '../../core/fs-utils.js';
11
- import { clone, pull, deriveNameFromUrl, currentSha, gitSync, isGitRepo } from '../../core/git.js';
12
- import { readMarketplaceManifest, readPluginManifest } from '../../core/manifest.js';
10
+ import { pathExists, ensureDir, removePath, nowIso, linkOrCopy, isSymlink, readSymlinkTarget, atomicWriteJson, readText, walkFiles } from '../../core/fs-utils.js';
11
+ import { clone, deriveNameFromUrl, currentSha, gitSync, isGitRepo } from '../../core/git.js';
12
+ import { readMarketplaceManifest, readPluginManifest, validatePluginManifestMemoryExtensions } from '../../core/manifest.js';
13
+ import { memoryExtensionValidationCatalog, validateMemoryExtensionValues } from '../../core/memory/extensions.js';
14
+ import { lintSubstrateFrontmatter } from '../../core/substrate/frontmatter-validation.js';
15
+ import { parseFrontmatterGeneric } from '../../core/frontmatter.js';
13
16
  import { validatePluginCommands, validateEffectiveCommandPlugins } from '../../core/command-plugins/discovery.js';
14
17
  import { normalizeEndpoint, validateEndpoint, validateHttpPluginTransport } from '../../core/command-plugins/endpoint.js';
15
18
  import { buildBrokerEnv, resolvePathExecutable } from '../../core/runtime/spawn-env.js';
@@ -118,6 +121,39 @@ function assertManifestPageComponentsValid(manifest, where) {
118
121
  throw general(`plugin manifest at ${where} declares an invalid page_components block:\n${reasons.map((r) => `- ${r}`).join('\n')}`, { issues: reasons });
119
122
  }
120
123
  }
124
+ /** Validate a package candidate before it gains an active plugin path. The
125
+ * candidate namespace shadows any installed declaration of the same name. */
126
+ function assertPluginCandidateValid(manifest, root, scope, scopeRootPath, where) {
127
+ try {
128
+ validatePluginManifestMemoryExtensions(manifest);
129
+ }
130
+ catch (error) {
131
+ const detail = error instanceof Error ? error.message : String(error);
132
+ throw general(`plugin manifest at ${where} declares invalid memory_extensions: ${detail}`);
133
+ }
134
+ assertManifestKindsValid(manifest, where);
135
+ assertManifestPageComponentsValid(manifest, where);
136
+ assertManifestBinValid(manifest, where);
137
+ assertManifestRequiresValid(manifest, where);
138
+ const memoryDir = join(root, 'memory');
139
+ const catalogDocPath = join(scopeRootPath, 'memory', '__plugin_candidate__.md');
140
+ for (const file of walkFiles(memoryDir, (name) => name.endsWith('.md'), (dir) => dir.startsWith('.'))) {
141
+ let frontmatter;
142
+ try {
143
+ frontmatter = parseFrontmatterGeneric(readText(file)).data;
144
+ }
145
+ catch (error) {
146
+ throw general(`plugin memory document ${relative(root, file)} has invalid YAML frontmatter: ${error instanceof Error ? error.message.split('\n')[0] : String(error)}`);
147
+ }
148
+ const substrateError = lintSubstrateFrontmatter(frontmatter);
149
+ if (substrateError !== null) {
150
+ throw general(`plugin memory document ${relative(root, file)} is invalid: ${substrateError}`);
151
+ }
152
+ for (const issue of validateMemoryExtensionValues(frontmatter?.['extensions'], memoryExtensionValidationCatalog({ scope, path: catalogDocPath }, { candidate: manifest }))) {
153
+ throw general(`plugin memory document ${relative(root, file)} is invalid: ${issue.path}: ${issue.message}`);
154
+ }
155
+ }
156
+ }
121
157
  function invocationTransport(bundle) {
122
158
  return { kind: 'http', endpoint: new URL(bundle.endpoint).origin, ...(bundle.authEnv !== undefined ? { authEnv: bundle.authEnv } : {}) };
123
159
  }
@@ -139,7 +175,8 @@ function writeStagedBundle(stagingRoot, manifest, bundle) {
139
175
  writeFileSync(target, document.bytes);
140
176
  }
141
177
  }
142
- async function validateStagedBundle(name, scope, root, manifest) {
178
+ async function validateStagedBundle(name, scope, root, scopeRootPath, manifest) {
179
+ assertPluginCandidateValid(manifest, root, scope, scopeRootPath, `${root}/.crouter-plugin/plugin.json`);
143
180
  const staged = { name, scope, root, manifest, enabled: true };
144
181
  const validation = validatePluginCommands(staged, new Set(SUBTREE_NAMES), await coreCommandPaths());
145
182
  if (validation.issues.length > 0) {
@@ -198,6 +235,7 @@ async function replaceBundlePlugin(name, bundleSource, scope, options) {
198
235
  // copy is what makes the components authorable — and dropping it on a
199
236
  // later archive that no longer declares them is what retires them.
200
237
  ...(validated.bundle.page_components !== undefined ? { page_components: validated.bundle.page_components } : {}),
238
+ ...(validated.bundle.memory_extensions !== undefined ? { memory_extensions: validated.bundle.memory_extensions } : {}),
201
239
  };
202
240
  const tmpRoot = join(scopeRootPath, 'tmp');
203
241
  const staging = join(tmpRoot, `${name}.${process.pid}`);
@@ -210,7 +248,7 @@ async function replaceBundlePlugin(name, bundleSource, scope, options) {
210
248
  try {
211
249
  ensureDir(staging);
212
250
  writeStagedBundle(staging, manifest, validated.bundle);
213
- await validateStagedBundle(name, scope, staging, manifest);
251
+ await validateStagedBundle(name, scope, staging, scopeRootPath, manifest);
214
252
  let movedPrevious = false;
215
253
  if (pathExists(root)) {
216
254
  renameSync(root, previous);
@@ -265,21 +303,6 @@ function classifyInstallRef(raw) {
265
303
  function isMarketplaceRelativeSource(source) {
266
304
  return source.startsWith('./') || source.startsWith('../') || (!isAbsolute(source) && !isGitUrl(source));
267
305
  }
268
- function pullGitCheckout(root, label, opts) {
269
- const shaBefore = currentSha(root);
270
- if (!isGitRepo(root)) {
271
- return { updated: false, sha: shaBefore ?? '' };
272
- }
273
- const res = pull(root);
274
- if (res.status !== 0) {
275
- if (opts.strict) {
276
- throw general(`git pull failed for ${label}: ${res.stderr.trim()}`);
277
- }
278
- return { updated: false, sha: shaBefore ?? '' };
279
- }
280
- const shaAfter = currentSha(root);
281
- return { updated: shaBefore !== shaAfter, sha: shaAfter ?? '' };
282
- }
283
306
  function refreshMarketplace(name, cache, opts) {
284
307
  const cached = cache.get(name);
285
308
  if (cached !== undefined)
@@ -290,28 +313,70 @@ function refreshMarketplace(name, cache, opts) {
290
313
  throw notFound(`marketplace not found: ${name}`);
291
314
  return null;
292
315
  }
293
- const pullSummary = pullGitCheckout(marketplace.root, `marketplace "${name}"`, opts);
294
- const freshManifest = readMarketplaceManifest(marketplace.root);
295
- if (freshManifest === null) {
296
- throw notFound(`marketplace manifest not found after refresh: ${marketplace.root}/.crouter-marketplace/marketplace.json`);
297
- }
298
- updateState(marketplace.scope, (s) => {
299
- if (s.marketplaces[name] === undefined) {
300
- s.marketplaces[name] = {};
316
+ const activeManifest = readMarketplaceManifest(marketplace.root);
317
+ if (activeManifest === null)
318
+ throw notFound(`marketplace manifest not found: ${marketplace.root}/.crouter-marketplace/marketplace.json`);
319
+ if (!isGitRepo(marketplace.root)) {
320
+ const unchanged = { marketplace, manifest: activeManifest, pull: { updated: false, sha: currentSha(marketplace.root) ?? '' } };
321
+ cache.set(name, unchanged);
322
+ return unchanged;
323
+ }
324
+ const activeSha = currentSha(marketplace.root);
325
+ const staging = join(dirname(marketplace.root), `.${name}.${process.pid}.${Date.now()}.candidate`);
326
+ const previous = `${staging}.previous`;
327
+ try {
328
+ clone(marketplace.url, staging, { ref: marketplace.ref, depth: 1 });
329
+ const manifest = readMarketplaceManifest(staging);
330
+ if (manifest === null)
331
+ throw notFound(`marketplace manifest not found in candidate checkout: ${staging}/.crouter-marketplace/marketplace.json`);
332
+ const observers = listAllPlugins().filter((plugin) => plugin.sourceMarketplace === name && isSymlink(plugin.root));
333
+ for (const plugin of observers) {
334
+ const entry = manifest.plugins.find((candidate) => candidate.name === plugin.name);
335
+ if (entry === undefined || !isMarketplaceRelativeSource(entry.source)) {
336
+ throw general(`marketplace candidate removes relative-source plugin "${plugin.name}" while it is installed`);
337
+ }
338
+ const symlinkTarget = readSymlinkTarget(plugin.root);
339
+ if (symlinkTarget === null)
340
+ throw general(`installed marketplace plugin "${plugin.name}" has no readable symlink target`);
341
+ const observedRelativeSource = relative(marketplace.root, resolve(dirname(plugin.root), symlinkTarget));
342
+ const candidateRoot = resolve(staging, entry.source);
343
+ if (candidateRoot !== resolve(staging, observedRelativeSource)) {
344
+ throw general(`marketplace candidate moves installed relative-source plugin "${plugin.name}" from ${observedRelativeSource} to ${entry.source}`);
345
+ }
346
+ if (!pathExists(candidateRoot))
347
+ throw notFound(`marketplace candidate source path does not exist for plugin "${plugin.name}": ${candidateRoot}`);
348
+ const candidateManifest = readPluginManifest(candidateRoot);
349
+ if (candidateManifest === null)
350
+ throw notFound(`plugin manifest not found in marketplace candidate: ${candidateRoot}/.crouter-plugin/plugin.json`);
351
+ const pluginScopeRoot = plugin.scope === 'project' ? requireScopeRoot('project') : userScopeRoot();
352
+ assertPluginCandidateValid(candidateManifest, candidateRoot, plugin.scope, pluginScopeRoot, `${candidateRoot}/.crouter-plugin/plugin.json`);
301
353
  }
302
- s.marketplaces[name].last_updated = nowIso();
303
- });
304
- const refreshed = { marketplace, manifest: freshManifest, pull: pullSummary };
305
- cache.set(name, refreshed);
306
- return refreshed;
307
- }
308
- /** Restore a source checkout after an updated manifest fails its install-time
309
- * gate. `--merge` returns it to the previous commit without discarding local
310
- * changes unrelated to the pull. */
311
- function restoreSourceCheckout(root, previousSha) {
312
- const restored = gitSync(['reset', '--merge', previousSha], root);
313
- if (restored.status !== 0) {
314
- throw general(`updated plugin manifest was rejected, but crouter could not restore the previous checkout at ${root}: ${restored.stderr.trim()}`);
354
+ renameSync(marketplace.root, previous);
355
+ try {
356
+ renameSync(staging, marketplace.root);
357
+ }
358
+ catch (error) {
359
+ renameSync(previous, marketplace.root);
360
+ throw error;
361
+ }
362
+ removePath(previous);
363
+ for (const plugin of observers) {
364
+ const entry = manifest.plugins.find((candidate) => candidate.name === plugin.name);
365
+ updatePluginVersion(plugin.scope, plugin.name, entry.version ?? plugin.manifest.version);
366
+ }
367
+ updateState(marketplace.scope, (s) => {
368
+ if (s.marketplaces[name] === undefined)
369
+ s.marketplaces[name] = {};
370
+ s.marketplaces[name].last_updated = nowIso();
371
+ });
372
+ const refreshed = { marketplace, manifest, pull: { updated: activeSha !== currentSha(marketplace.root), sha: currentSha(marketplace.root) ?? '' } };
373
+ cache.set(name, refreshed);
374
+ return refreshed;
375
+ }
376
+ finally {
377
+ removePath(staging);
378
+ if (pathExists(previous) && !pathExists(marketplace.root))
379
+ renameSync(previous, marketplace.root);
315
380
  }
316
381
  }
317
382
  function updatePluginVersion(scope, name, version) {
@@ -336,46 +401,28 @@ function installFromGit(source, ref, scope) {
336
401
  const scopeRootPath = scope === 'project' ? ensureProjectScopeRoot() : userScopeRoot();
337
402
  ensureScopeInitialized(scope, scopeRootPath);
338
403
  const pDir = join(scopeRootPath, 'plugins');
339
- ensureDir(pDir);
340
404
  const tempName = deriveNameFromUrl(source);
341
- const tempDir = join(pDir, tempName);
342
- if (pathExists(tempDir)) {
343
- throw general(`plugin directory already exists: ${tempDir}\nRemove the existing plugin first with \`pkg plugin remove ${tempName}\`.`);
344
- }
345
- clone(source, tempDir, { ref: ref !== undefined ? ref : undefined, depth: 1 });
346
- const manifest = readPluginManifest(tempDir);
347
- if (manifest === null) {
348
- removePath(tempDir);
349
- throw general(`cloned repo does not contain a valid .crouter-plugin/plugin.json: ${source}`);
350
- }
405
+ const staging = join(scopeRootPath, 'tmp', `${tempName}.${process.pid}.${Date.now()}`);
406
+ ensureDir(join(scopeRootPath, 'tmp'));
351
407
  try {
352
- assertManifestKindsValid(manifest, `${source}/.crouter-plugin/plugin.json`);
353
- assertManifestPageComponentsValid(manifest, `${source}/.crouter-plugin/plugin.json`);
354
- assertManifestBinValid(manifest, `${source}/.crouter-plugin/plugin.json`);
355
- assertManifestRequiresValid(manifest, `${source}/.crouter-plugin/plugin.json`);
408
+ clone(source, staging, { ref, depth: 1 });
409
+ const manifest = readPluginManifest(staging);
410
+ if (manifest === null)
411
+ throw general(`cloned repo does not contain a valid .crouter-plugin/plugin.json: ${source}`);
412
+ assertPluginCandidateValid(manifest, staging, scope, scopeRootPath, `${source}/.crouter-plugin/plugin.json`);
413
+ const finalDir = join(pDir, manifest.name);
414
+ if (pathExists(finalDir))
415
+ throw general(`plugin "${manifest.name}" is already installed at ${finalDir}`);
416
+ ensureDir(pDir);
417
+ renameSync(staging, finalDir);
418
+ updateConfig(scope, (cfg) => {
419
+ cfg.plugins[manifest.name] = { enabled: true, version: manifest.version };
420
+ });
421
+ return { name: manifest.name, scope, path: finalDir };
356
422
  }
357
- catch (error) {
358
- removePath(tempDir);
359
- throw error;
360
- }
361
- const finalName = manifest.name;
362
- let finalDir = tempDir;
363
- if (finalName !== tempName) {
364
- const candidateDir = join(pDir, finalName);
365
- if (pathExists(candidateDir)) {
366
- removePath(tempDir);
367
- throw general(`plugin "${finalName}" is already installed at ${candidateDir}`);
368
- }
369
- renameSync(tempDir, candidateDir);
370
- finalDir = candidateDir;
423
+ finally {
424
+ removePath(staging);
371
425
  }
372
- updateConfig(scope, (cfg) => {
373
- cfg.plugins[finalName] = {
374
- enabled: true,
375
- version: manifest.version,
376
- };
377
- });
378
- return { name: finalName, scope, path: finalDir };
379
426
  }
380
427
  function installFromLocal(source, scope) {
381
428
  const sourcePath = resolve(source);
@@ -383,11 +430,8 @@ function installFromLocal(source, scope) {
383
430
  if (manifest === null) {
384
431
  throw notFound(`plugin manifest not found at ${sourcePath}/.crouter-plugin/plugin.json`);
385
432
  }
386
- assertManifestKindsValid(manifest, `${sourcePath}/.crouter-plugin/plugin.json`);
387
- assertManifestPageComponentsValid(manifest, `${sourcePath}/.crouter-plugin/plugin.json`);
388
- assertManifestBinValid(manifest, `${sourcePath}/.crouter-plugin/plugin.json`);
389
- assertManifestRequiresValid(manifest, `${sourcePath}/.crouter-plugin/plugin.json`);
390
433
  const scopeRootPath = scope === 'project' ? ensureProjectScopeRoot() : userScopeRoot();
434
+ assertPluginCandidateValid(manifest, sourcePath, scope, scopeRootPath, `${sourcePath}/.crouter-plugin/plugin.json`);
391
435
  ensureScopeInitialized(scope, scopeRootPath);
392
436
  const destDir = join(scopeRootPath, 'plugins', manifest.name);
393
437
  if (pathExists(destDir)) {
@@ -416,33 +460,36 @@ export function installFromMarketplace(ref, scope) {
416
460
  const destRoot = requireScopeRoot(scope);
417
461
  ensureScopeInitialized(scope, destRoot);
418
462
  const destPluginDir = join(destRoot, 'plugins', ref.plugin);
463
+ if (pathExists(destPluginDir))
464
+ throw general(`plugin "${ref.plugin}" is already installed at ${destPluginDir}`);
465
+ let pluginManifest;
419
466
  if (isMarketplaceRelativeSource(entry.source)) {
420
467
  const sourcePath = join(refreshed.marketplace.root, entry.source);
421
- if (!pathExists(sourcePath)) {
468
+ if (!pathExists(sourcePath))
422
469
  throw notFound(`plugin source path does not exist: ${sourcePath}`);
423
- }
470
+ const candidateManifest = readPluginManifest(sourcePath);
471
+ if (candidateManifest === null)
472
+ throw notFound(`plugin manifest not found at ${sourcePath}/.crouter-plugin/plugin.json`);
473
+ assertPluginCandidateValid(candidateManifest, sourcePath, scope, destRoot, `${sourcePath}/.crouter-plugin/plugin.json`);
424
474
  linkOrCopy(sourcePath, destPluginDir);
475
+ pluginManifest = candidateManifest;
425
476
  }
426
477
  else {
427
- if (pathExists(destPluginDir)) {
428
- removePath(destPluginDir);
478
+ const staging = join(destRoot, 'tmp', `${ref.plugin}.${process.pid}.${Date.now()}`);
479
+ ensureDir(join(destRoot, 'tmp'));
480
+ try {
481
+ clone(entry.source, staging, { depth: 1 });
482
+ const candidateManifest = readPluginManifest(staging);
483
+ if (candidateManifest === null)
484
+ throw notFound(`plugin manifest not found at ${staging}/.crouter-plugin/plugin.json`);
485
+ assertPluginCandidateValid(candidateManifest, staging, scope, destRoot, `${staging}/.crouter-plugin/plugin.json`);
486
+ ensureDir(join(destRoot, 'plugins'));
487
+ renameSync(staging, destPluginDir);
488
+ pluginManifest = candidateManifest;
489
+ }
490
+ finally {
491
+ removePath(staging);
429
492
  }
430
- clone(entry.source, destPluginDir, { depth: 1 });
431
- }
432
- const pluginManifest = readPluginManifest(destPluginDir);
433
- if (pluginManifest === null) {
434
- removePath(destPluginDir);
435
- throw notFound(`plugin manifest not found at ${destPluginDir}/.crouter-plugin/plugin.json`);
436
- }
437
- try {
438
- assertManifestKindsValid(pluginManifest, `${destPluginDir}/.crouter-plugin/plugin.json`);
439
- assertManifestPageComponentsValid(pluginManifest, `${destPluginDir}/.crouter-plugin/plugin.json`);
440
- assertManifestBinValid(pluginManifest, `${destPluginDir}/.crouter-plugin/plugin.json`);
441
- assertManifestRequiresValid(pluginManifest, `${destPluginDir}/.crouter-plugin/plugin.json`);
442
- }
443
- catch (error) {
444
- removePath(destPluginDir);
445
- throw error;
446
493
  }
447
494
  const version = entry.version ?? pluginManifest.version;
448
495
  updateConfig(scope, (cfg) => {
@@ -456,6 +503,58 @@ export function installFromMarketplace(ref, scope) {
456
503
  });
457
504
  return { name: ref.plugin, scope, path: destPluginDir };
458
505
  }
506
+ function updateGitPluginCandidate(plugin) {
507
+ if (isSymlink(plugin.root)) {
508
+ const manifest = readPluginManifest(plugin.root);
509
+ if (manifest === null)
510
+ throw notFound(`plugin manifest not found at ${plugin.root}/.crouter-plugin/plugin.json`);
511
+ const scopeRootPath = plugin.scope === 'project' ? requireScopeRoot('project') : userScopeRoot();
512
+ assertPluginCandidateValid(manifest, plugin.root, plugin.scope, scopeRootPath, `${plugin.root}/.crouter-plugin/plugin.json`);
513
+ return { updated: false, sha: currentSha(plugin.root) ?? '', manifest };
514
+ }
515
+ if (!isGitRepo(plugin.root)) {
516
+ const manifest = readPluginManifest(plugin.root);
517
+ if (manifest === null)
518
+ throw notFound(`plugin manifest not found at ${plugin.root}/.crouter-plugin/plugin.json`);
519
+ const scopeRootPath = plugin.scope === 'project' ? requireScopeRoot('project') : userScopeRoot();
520
+ assertPluginCandidateValid(manifest, plugin.root, plugin.scope, scopeRootPath, `${plugin.root}/.crouter-plugin/plugin.json`);
521
+ return { updated: false, sha: '', manifest };
522
+ }
523
+ const remote = gitSync(['config', '--get', 'remote.origin.url'], plugin.root).stdout.trim();
524
+ if (remote === '')
525
+ throw general(`plugin "${plugin.name}" has no origin remote to stage an update from`);
526
+ const branch = gitSync(['symbolic-ref', '--short', 'HEAD'], plugin.root).stdout.trim();
527
+ if (branch === '')
528
+ throw general(`plugin "${plugin.name}" is detached at its installed revision and has no tracked branch to update`);
529
+ const scopeRootPath = plugin.scope === 'project' ? requireScopeRoot('project') : userScopeRoot();
530
+ const staging = join(scopeRootPath, 'tmp', `${plugin.name}.${process.pid}.${Date.now()}.candidate`);
531
+ const previous = `${staging}.previous`;
532
+ const before = currentSha(plugin.root);
533
+ ensureDir(join(scopeRootPath, 'tmp'));
534
+ try {
535
+ clone(remote, staging, { ref: branch, depth: 1 });
536
+ const manifest = readPluginManifest(staging);
537
+ if (manifest === null)
538
+ throw notFound(`plugin manifest not found in update candidate: ${staging}/.crouter-plugin/plugin.json`);
539
+ assertPluginCandidateValid(manifest, staging, plugin.scope, scopeRootPath, `${staging}/.crouter-plugin/plugin.json`);
540
+ renameSync(plugin.root, previous);
541
+ try {
542
+ renameSync(staging, plugin.root);
543
+ }
544
+ catch (error) {
545
+ renameSync(previous, plugin.root);
546
+ throw error;
547
+ }
548
+ removePath(previous);
549
+ const sha = currentSha(plugin.root) ?? '';
550
+ return { updated: before !== sha, sha, manifest };
551
+ }
552
+ finally {
553
+ removePath(staging);
554
+ if (pathExists(previous) && !pathExists(plugin.root))
555
+ renameSync(previous, plugin.root);
556
+ }
557
+ }
459
558
  async function updateOnePlugin(plugin, marketplaceCache, opts) {
460
559
  const sourceInstalled = isSourceInstalled(plugin);
461
560
  if (!sourceInstalled && plugin.manifest.bundle !== undefined) {
@@ -464,63 +563,25 @@ async function updateOnePlugin(plugin, marketplaceCache, opts) {
464
563
  }
465
564
  if (plugin.manifest.transport?.kind === 'http' && !sourceInstalled)
466
565
  rejectLegacyHttpPlugin(plugin.name);
467
- const previousSha = currentSha(plugin.root);
468
- let contentUpdate = { updated: false, sha: currentSha(plugin.root) ?? '' };
469
566
  let marketplaceVersion;
567
+ let marketplaceUpdated = false;
470
568
  if (plugin.sourceMarketplace !== undefined) {
471
569
  const refreshed = refreshMarketplace(plugin.sourceMarketplace, marketplaceCache, opts);
472
570
  if (refreshed !== null) {
473
- const entry = refreshed.manifest.plugins.find((p) => p.name === plugin.name);
571
+ const entry = refreshed.manifest.plugins.find((candidate) => candidate.name === plugin.name);
474
572
  marketplaceVersion = entry?.version;
475
- if (isSymlink(plugin.root)) {
476
- const shaAfter = currentSha(plugin.root);
477
- contentUpdate = { updated: refreshed.pull.updated, sha: shaAfter ?? refreshed.pull.sha };
478
- }
479
- else {
480
- contentUpdate = pullGitCheckout(plugin.root, `plugin "${plugin.name}"`, opts);
481
- }
482
- }
483
- }
484
- else {
485
- contentUpdate = pullGitCheckout(plugin.root, `plugin "${plugin.name}"`, opts);
486
- }
487
- const manifest = readPluginManifest(plugin.root);
488
- try {
489
- if (manifest === null) {
490
- throw general(`plugin manifest not found after updating ${plugin.root}: ${plugin.root}/.crouter-plugin/plugin.json`);
491
- }
492
- assertManifestKindsValid(manifest, `${plugin.root}/.crouter-plugin/plugin.json`);
493
- assertManifestPageComponentsValid(manifest, `${plugin.root}/.crouter-plugin/plugin.json`);
494
- assertManifestBinValid(manifest, `${plugin.root}/.crouter-plugin/plugin.json`);
495
- assertManifestRequiresValid(manifest, `${plugin.root}/.crouter-plugin/plugin.json`);
496
- }
497
- catch (error) {
498
- if (contentUpdate.updated && previousSha !== null && isGitRepo(plugin.root)) {
499
- restoreSourceCheckout(plugin.root, previousSha);
500
- // Marketplace plugins are symlinks into the shared marketplace checkout.
501
- // Its cached manifest and pull state must move back with that checkout so
502
- // later entries in this bulk update neither see the rejected revision nor
503
- // record its versions as accepted.
504
- if (plugin.sourceMarketplace !== undefined) {
505
- const cached = marketplaceCache.get(plugin.sourceMarketplace);
506
- if (cached !== undefined) {
507
- marketplaceCache.set(plugin.sourceMarketplace, {
508
- ...cached,
509
- pull: { updated: false, sha: currentSha(cached.marketplace.root) ?? '' },
510
- });
511
- }
512
- }
573
+ marketplaceUpdated = refreshed.pull.updated;
513
574
  }
514
- throw error;
515
575
  }
516
- updatePluginVersion(plugin.scope, plugin.name, marketplaceVersion ?? manifest.version);
576
+ const candidate = updateGitPluginCandidate(plugin);
577
+ updatePluginVersion(plugin.scope, plugin.name, marketplaceVersion ?? candidate.manifest.version);
517
578
  const commands = await commandReport(plugin.name, plugin.scope);
518
- const warnings = missingRequirementWarnings(manifest);
579
+ const warnings = missingRequirementWarnings(candidate.manifest);
519
580
  return {
520
581
  name: plugin.name,
521
582
  ...(plugin.manifest.transport !== undefined ? { transport: plugin.manifest.transport.kind } : {}),
522
- updated: contentUpdate.updated,
523
- sha: contentUpdate.sha,
583
+ updated: marketplaceUpdated || candidate.updated,
584
+ sha: candidate.sha,
524
585
  ...(commands !== undefined ? { commands } : {}),
525
586
  ...(warnings.length > 0 ? { warnings } : {}),
526
587
  };
@@ -13,7 +13,7 @@ import { createNode } from '../canvas/canvas.js';
13
13
  import { closeDb } from '../canvas/db.js';
14
14
  import { nodeDir } from '../canvas/paths.js';
15
15
  import { atomicWriteJson, bindTicketReplyRoute, pageManifestPath } from '../human/convention.js';
16
- import { cancelTicket } from '../human/tickets.js';
16
+ import { cancelTicket, readTicketResult } from '../human/tickets.js';
17
17
  import { humanCancel } from '../../commands/human/queue.js';
18
18
  import { createApiServer } from '../../daemon/api/server.js';
19
19
  import { apiSocketPath } from '../canvas/paths.js';
@@ -46,7 +46,7 @@ function node(id) {
46
46
  }
47
47
  /** Publish a reply-bearing page ticket in the bridge node's directory, exactly as
48
48
  * `human send` does: reply route first, then the page manifest. */
49
- function publishTicket(id) {
49
+ function publishTicket(id, slots = []) {
50
50
  const dir = nodeDir(id);
51
51
  mkdirSync(dir, { recursive: true });
52
52
  bindTicketReplyRoute(dir, id);
@@ -58,7 +58,7 @@ function publishTicket(id) {
58
58
  delivery: { placement: 'inline', inbox: true, reply: true },
59
59
  document: 'page.tsx',
60
60
  steps: 1,
61
- slots: [],
61
+ slots,
62
62
  });
63
63
  }
64
64
  before(() => {
@@ -80,13 +80,31 @@ after(() => {
80
80
  rmSync(home, { recursive: true, force: true });
81
81
  delete process.env['CRTR_HOME'];
82
82
  });
83
- test('cancel on an already-canceled ticket is a no-op, never throws on the finalize', async () => {
84
- const id = 'canceledJob';
85
- createNode(node(id));
86
- publishTicket(id);
87
- cancelTicket(nodeDir(id), { actor: 'human' }); // settled by the first cancel
88
- const res = (await humanCancel.run({ ticket_id: id }));
89
- assert.equal(res['canceled'], false);
90
- assert.equal(res['reason'], 'already_resolved');
91
- assert.equal(res['ticket_id'], id);
83
+ test('cancel accepts newer page config and treats an already-canceled ticket as a no-op', async () => {
84
+ const newerId = 'newerPage';
85
+ createNode(node(newerId));
86
+ publishTicket(newerId, [{
87
+ id: 'decision',
88
+ kind: 'options',
89
+ step: 0,
90
+ config: {
91
+ label: 'Decision',
92
+ body: 'Choose one.',
93
+ options: [{ id: 'yes', label: 'Yes' }],
94
+ mode: 'single',
95
+ futureField: true,
96
+ },
97
+ }]);
98
+ const canceled = (await humanCancel.run({ ticket_id: newerId }));
99
+ assert.equal(canceled['canceled'], true);
100
+ assert.equal(canceled['ticket_id'], newerId);
101
+ assert.equal(readTicketResult(nodeDir(newerId))?.kind, 'canceled');
102
+ const settledId = 'canceledJob';
103
+ createNode(node(settledId));
104
+ publishTicket(settledId);
105
+ cancelTicket(nodeDir(settledId), { actor: 'human' });
106
+ const settled = (await humanCancel.run({ ticket_id: settledId }));
107
+ assert.equal(settled['canceled'], false);
108
+ assert.equal(settled['reason'], 'already_resolved');
109
+ assert.equal(settled['ticket_id'], settledId);
92
110
  });
@@ -77,11 +77,8 @@ export declare function jobStartedAtMs(jobId: string): number | undefined;
77
77
  * notice, the `node bash background` result/toast, and the attach cockpit)
78
78
  * shows it: `<60s -> "12s"`; `60s-59m -> "3m 40s"`; `>=60m -> "1h 04m"`. */
79
79
  export declare function formatBashElapsed(ms: number): string;
80
- /** The command line worth SHOWING for a job. `cmd.sh` opens with the harness's
81
- * injected environment preamble (`export CRTR_PREVIEW_RESULT_PATH=…`), so its
82
- * literal first line names no command at all: skip leading assignment lines and
83
- * return the first real one, whitespace-collapsed to a single line. A job whose
84
- * command is nothing but assignments falls back to its first non-empty line. */
80
+ /** The command line worth SHOWING for a job: the first non-empty line of
81
+ * `cmd.sh`, whitespace-collapsed to one line. */
85
82
  export declare function bashJobCommandLine(command: string): string;
86
83
  export interface BashJobStatus {
87
84
  jobId: string;
@@ -189,15 +189,11 @@ export function formatBashElapsed(ms) {
189
189
  const minutes = totalMinutes % 60;
190
190
  return `${hours}h ${String(minutes).padStart(2, '0')}m`;
191
191
  }
192
- /** The command line worth SHOWING for a job. `cmd.sh` opens with the harness's
193
- * injected environment preamble (`export CRTR_PREVIEW_RESULT_PATH=…`), so its
194
- * literal first line names no command at all: skip leading assignment lines and
195
- * return the first real one, whitespace-collapsed to a single line. A job whose
196
- * command is nothing but assignments falls back to its first non-empty line. */
192
+ /** The command line worth SHOWING for a job: the first non-empty line of
193
+ * `cmd.sh`, whitespace-collapsed to one line. */
197
194
  export function bashJobCommandLine(command) {
198
- const lines = command.split('\n').map((line) => line.trim()).filter((line) => line !== '');
199
- const real = lines.find((line) => !/^(export\s+)?[A-Za-z_][A-Za-z0-9_]*=/.test(line));
200
- return (real ?? lines[0] ?? '').replace(/\s+/g, ' ');
195
+ const first = command.split('\n').map((line) => line.trim()).find((line) => line !== '');
196
+ return (first ?? '').replace(/\s+/g, ' ');
201
197
  }
202
198
  /** Every live backgrounded bash job for this node — job.bg present, job.exit
203
199
  * absent — whether handed off by the menu, the valve deadline, or broker
@@ -1,5 +1,5 @@
1
1
  import { type ValidatedCommandManifest } from '../command-manifests/manifest.js';
2
- import type { KindConfig, PageComponentRegistration } from '../../types.js';
2
+ import type { KindConfig, MemoryExtensionDeclarations, PageComponentRegistration } from '../../types.js';
3
3
  export interface BundleIssue {
4
4
  code: string;
5
5
  path?: string;
@@ -20,6 +20,9 @@ export interface ValidatedBundle {
20
20
  * manifest, where `resolvePageComponents` reads it — see
21
21
  * `PluginManifest.page_components`. */
22
22
  page_components?: PageComponentRegistration[];
23
+ /** Plugin-owned memory metadata declarations copied into the synthesized
24
+ * installed manifest alongside the archive's memory documents. */
25
+ memory_extensions?: MemoryExtensionDeclarations;
23
26
  commands: ValidatedCommandManifest;
24
27
  commandsBytes: Uint8Array;
25
28
  directories: ReadonlyArray<{
@@ -2,6 +2,7 @@ import { Parser } from 'tar';
2
2
  import { validateCommandManifest } from '../command-manifests/manifest.js';
3
3
  import { invalidPluginKindsReasons } from '../config.js';
4
4
  import { invalidPageComponentsReasons, normalizePageComponents } from '../human/page-catalog.js';
5
+ import { validateMemoryExtensionDeclarations } from '../memory/extensions.js';
5
6
  function issue(code, message, received, expected, next, path) {
6
7
  return { code, message, received, expected, next, ...(path !== undefined ? { path } : {}) };
7
8
  }
@@ -144,10 +145,10 @@ function parseBundleMetadata(bytes) {
144
145
  };
145
146
  }
146
147
  const record = raw;
147
- const unknownKeys = Object.keys(record).filter((key) => key !== 'bundleVersion' && key !== 'kinds' && key !== 'page_components');
148
+ const unknownKeys = Object.keys(record).filter((key) => key !== 'bundleVersion' && key !== 'kinds' && key !== 'page_components' && key !== 'memory_extensions');
148
149
  if (record['bundleVersion'] !== 1 || unknownKeys.length > 0) {
149
150
  return {
150
- issue: bundleInvalid('bundle.json must contain bundleVersion 1 and at most kinds and page_components blocks', JSON.stringify(raw), 'an object with bundleVersion set to 1 and optionally kinds and page_components', 'Set bundle.json to {"bundleVersion":1} plus an optional kinds object and page_components array.', 'bundle.json'),
151
+ issue: bundleInvalid('bundle.json must contain bundleVersion 1 and at most kinds, page_components, and memory_extensions blocks', JSON.stringify(raw), 'an object with bundleVersion set to 1 and optional kinds, page_components, and memory_extensions', 'Set bundle.json to {"bundleVersion":1} plus optional contribution blocks.', 'bundle.json'),
151
152
  };
152
153
  }
153
154
  // Install-time strictness: a bad block fails the install loudly here, because
@@ -169,9 +170,21 @@ function parseBundleMetadata(bytes) {
169
170
  };
170
171
  }
171
172
  }
173
+ let memoryExtensions;
174
+ if (record['memory_extensions'] !== undefined) {
175
+ try {
176
+ memoryExtensions = validateMemoryExtensionDeclarations(record['memory_extensions']);
177
+ }
178
+ catch (error) {
179
+ return {
180
+ issue: bundleInvalid('bundle.json memory_extensions block is invalid', error instanceof Error ? error.message : String(error), 'a valid memory_extensions declaration object', 'Fix the memory_extensions declarations in bundle.json.', 'bundle.json'),
181
+ };
182
+ }
183
+ }
172
184
  return {
173
185
  ...(record['kinds'] !== undefined ? { kinds: record['kinds'] } : {}),
174
186
  ...(record['page_components'] !== undefined ? { page_components: normalizePageComponents(record['page_components']) } : {}),
187
+ ...(memoryExtensions !== undefined ? { memory_extensions: memoryExtensions } : {}),
175
188
  };
176
189
  }
177
190
  function parseCommands(bytes, reservedCoreNames, coreCommandPaths) {
@@ -219,6 +232,7 @@ export async function validatePluginBundle(archive, options) {
219
232
  bundleVersion: 1,
220
233
  ...(metadata.kinds !== undefined ? { kinds: metadata.kinds } : {}),
221
234
  ...(metadata.page_components !== undefined ? { page_components: metadata.page_components } : {}),
235
+ ...(metadata.memory_extensions !== undefined ? { memory_extensions: metadata.memory_extensions } : {}),
222
236
  commands: commandValidation.commands,
223
237
  commandsBytes: commands.bytes,
224
238
  directories: parsed.members