@north-light/crouter 0.3.170 → 0.3.171

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 (121) hide show
  1. package/dist/build-root.d.ts +1 -16
  2. package/dist/build-root.js +1 -25
  3. package/dist/builtin-memory/insights/capture.md +66 -0
  4. package/dist/builtin-memory/insights/init.md +45 -0
  5. package/dist/builtin-memory/internal/plugins.md +12 -11
  6. package/dist/cli.js +2 -6
  7. package/dist/clients/attach/__tests__/attach-keybindings.test.js +7 -1
  8. package/dist/clients/attach/__tests__/context-message.test.js +50 -3
  9. package/dist/clients/attach/__tests__/diagram.test.js +1 -1
  10. package/dist/clients/attach/chrome/header.d.ts +10 -0
  11. package/dist/clients/attach/chrome/header.js +35 -0
  12. package/dist/clients/attach/chrome/status-line.d.ts +3 -0
  13. package/dist/clients/attach/chrome/status-line.js +5 -0
  14. package/dist/clients/attach/chrome/widgets.js +7 -18
  15. package/dist/clients/attach/input/controller.d.ts +10 -0
  16. package/dist/clients/attach/input/controller.js +3 -0
  17. package/dist/clients/attach/overlays/help.d.ts +33 -0
  18. package/dist/clients/attach/overlays/help.js +204 -0
  19. package/dist/clients/attach/render/chat-view.d.ts +81 -9
  20. package/dist/clients/attach/render/chat-view.js +434 -90
  21. package/dist/clients/attach/render/context-message.js +3 -3
  22. package/dist/clients/attach/render/diagram.js +2 -2
  23. package/dist/clients/attach/render/measured-container.d.ts +94 -0
  24. package/dist/clients/attach/render/measured-container.js +307 -0
  25. package/dist/clients/attach/render/transcript-copy.d.ts +9 -0
  26. package/dist/clients/attach/render/transcript-copy.js +81 -0
  27. package/dist/clients/attach/render/viewport.d.ts +29 -0
  28. package/dist/clients/attach/render/viewport.js +160 -0
  29. package/dist/clients/attach/session/bindings.d.ts +5 -1
  30. package/dist/clients/attach/session/bindings.js +16 -0
  31. package/dist/clients/attach/session/context.d.ts +5 -3
  32. package/dist/clients/attach/session/frame.d.ts +34 -0
  33. package/dist/clients/attach/session/frame.js +173 -0
  34. package/dist/clients/attach/session/frames.d.ts +2 -0
  35. package/dist/clients/attach/session/frames.js +3 -0
  36. package/dist/clients/attach/session/input-wiring.d.ts +10 -4
  37. package/dist/clients/attach/session/input-wiring.js +13 -13
  38. package/dist/clients/attach/session/keys.d.ts +4 -1
  39. package/dist/clients/attach/session/keys.js +9 -4
  40. package/dist/clients/attach/session/layout.d.ts +10 -8
  41. package/dist/clients/attach/session/layout.js +32 -31
  42. package/dist/clients/attach/session/mouse.d.ts +15 -0
  43. package/dist/clients/attach/session/mouse.js +61 -0
  44. package/dist/clients/attach/slash/dispatch.d.ts +10 -0
  45. package/dist/clients/attach/slash/dispatch.js +35 -2
  46. package/dist/clients/attach/viewer.js +605 -581
  47. package/dist/clients/web/dev-server.d.ts +4 -1
  48. package/dist/clients/web/dev-server.js +8 -19
  49. package/dist/clients/web/web-cmd.js +1 -1
  50. package/dist/commands/human/prompts.js +2 -1
  51. package/dist/commands/pkg/plugin-manage.js +143 -68
  52. package/dist/commands/sys/__tests__/setup-core.test.js +4 -2
  53. package/dist/commands/sys/setup-core.js +21 -16
  54. package/dist/core/clipboard-text.d.ts +8 -0
  55. package/dist/core/clipboard-text.js +18 -0
  56. package/dist/core/command-manifests/registry.d.ts +1 -3
  57. package/dist/core/command-plugins/bundle.d.ts +29 -0
  58. package/dist/core/command-plugins/bundle.js +198 -0
  59. package/dist/core/command-plugins/discovery.d.ts +0 -8
  60. package/dist/core/command-plugins/discovery.js +2 -39
  61. package/dist/core/command-plugins/transport/http-fetch.d.ts +4 -30
  62. package/dist/core/command-plugins/transport/http-fetch.js +17 -95
  63. package/dist/core/command.d.ts +1 -13
  64. package/dist/core/command.js +1 -16
  65. package/dist/core/config.js +2 -1
  66. package/dist/core/keybindings/__tests__/resolve.test.js +4 -0
  67. package/dist/core/keybindings/attach-control.d.ts +3 -0
  68. package/dist/core/keybindings/attach-control.js +1 -0
  69. package/dist/core/keybindings/catalog.d.ts +2 -2
  70. package/dist/core/keybindings/catalog.js +8 -0
  71. package/dist/core/runtime/broker-protocol.d.ts +13 -1
  72. package/dist/core/runtime/broker.js +213 -5
  73. package/dist/core/runtime/canvas-extensions.d.ts +3 -0
  74. package/dist/core/runtime/canvas-extensions.js +3 -0
  75. package/dist/core/runtime/front-door.js +4 -4
  76. package/dist/core/runtime/kickoff.d.ts +1 -5
  77. package/dist/core/runtime/kickoff.js +1 -5
  78. package/dist/core/runtime/naming.d.ts +7 -0
  79. package/dist/core/runtime/naming.js +5 -2
  80. package/dist/core/runtime/node-read.js +15 -0
  81. package/dist/core/runtime/package-health.d.ts +6 -21
  82. package/dist/core/runtime/package-health.js +12 -123
  83. package/dist/core/runtime/stop-guard.d.ts +1 -1
  84. package/dist/core/runtime/stop-guard.js +3 -3
  85. package/dist/core/runtime/tool-group-summary.d.ts +19 -0
  86. package/dist/core/runtime/tool-group-summary.js +117 -0
  87. package/dist/core/termrender/code-doc.d.ts +12 -0
  88. package/dist/core/termrender/code-doc.js +91 -0
  89. package/dist/core/termrender/display.d.ts +12 -0
  90. package/dist/core/termrender/display.js +19 -0
  91. package/dist/core/termrender/termrender.d.ts +73 -0
  92. package/dist/core/termrender/termrender.js +795 -0
  93. package/dist/core/termrender/version.d.ts +1 -0
  94. package/dist/core/termrender/version.js +1 -0
  95. package/dist/core/user-settings.d.ts +14 -0
  96. package/dist/core/user-settings.js +20 -0
  97. package/dist/daemon/crtrd.js +5 -6
  98. package/dist/index.d.ts +2 -0
  99. package/dist/index.js +1 -0
  100. package/dist/pi-extensions/__tests__/canvas-structured-output.test.d.ts +1 -0
  101. package/dist/pi-extensions/__tests__/canvas-structured-output.test.js +63 -0
  102. package/dist/pi-extensions/canvas-stophook.js +2 -2
  103. package/dist/pi-extensions/canvas-structured-output.js +52 -8
  104. package/dist/pi-extensions/summary-tool.d.ts +33 -0
  105. package/dist/pi-extensions/summary-tool.js +62 -0
  106. package/dist/shared/generated-context.d.ts +19 -4
  107. package/dist/shared/generated-context.js +98 -7
  108. package/dist/shared/tool-groups.d.ts +23 -0
  109. package/dist/shared/tool-groups.js +60 -0
  110. package/dist/types.d.ts +11 -0
  111. package/dist/types.js +1 -0
  112. package/dist/web-client/assets/{index-U_NZ66VE.js → index-D7I3gHCL.js} +26 -19
  113. package/dist/web-client/index.html +1 -1
  114. package/dist/web-client/sw.js +1 -1
  115. package/package.json +3 -1
  116. package/runtime.lock.json +62 -3
  117. package/scripts/postinstall.mjs +14 -1
  118. package/dist/core/command-plugins/store.d.ts +0 -16
  119. package/dist/core/command-plugins/store.js +0 -64
  120. package/dist/core/runtime/fault-recovery-nudge.d.ts +0 -3
  121. package/dist/core/runtime/fault-recovery-nudge.js +0 -3
@@ -3,5 +3,8 @@ import type { ViteDevServer } from 'vite';
3
3
  /** Create a Vite dev server in middleware mode, HMR bound to `httpServer`.
4
4
  * Mirrors the old `view serve` resolve setup so the shell's bare imports
5
5
  * (react, the `@north-light/crouter/web` self-subpath) resolve from the
6
- * generated/shell root. Lazily imports vite so the shipped path never loads it. */
6
+ * generated/shell root. It loads the shell's Vite config rather than duplicating
7
+ * its plugins: that config supplies the PWA virtual-module resolver as well as
8
+ * the production build settings. Lazily imports vite so the shipped path never
9
+ * loads it. */
7
10
  export declare function createDevServer(httpServer: HttpServer): Promise<ViteDevServer>;
@@ -24,30 +24,19 @@ function resolveShellRoot(packageRoot) {
24
24
  /** Create a Vite dev server in middleware mode, HMR bound to `httpServer`.
25
25
  * Mirrors the old `view serve` resolve setup so the shell's bare imports
26
26
  * (react, the `@north-light/crouter/web` self-subpath) resolve from the
27
- * generated/shell root. Lazily imports vite so the shipped path never loads it. */
27
+ * generated/shell root. It loads the shell's Vite config rather than duplicating
28
+ * its plugins: that config supplies the PWA virtual-module resolver as well as
29
+ * the production build settings. Lazily imports vite so the shipped path never
30
+ * loads it. */
28
31
  export async function createDevServer(httpServer) {
29
32
  const packageRoot = resolve(HERE, '../../..');
30
- const nodeModules = join(packageRoot, 'node_modules');
31
33
  const shellRoot = resolveShellRoot(packageRoot);
32
34
  const { createServer } = await import('vite');
33
- const react = (await import('@vitejs/plugin-react')).default;
34
- const tailwindcss = (await import('@tailwindcss/vite')).default;
35
35
  return createServer({
36
- configFile: false,
37
- root: shellRoot,
38
- plugins: [react(), tailwindcss()],
39
- appType: 'spa',
40
- resolve: {
41
- alias: {
42
- react: join(nodeModules, 'react'),
43
- 'react-dom': join(nodeModules, 'react-dom'),
44
- '@': shellRoot,
45
- // The crtr web runtime is a self-subpath of crouter's own package;
46
- // pin it to the built barrel so the shell can import it from any root.
47
- '@north-light/crouter/web': join(packageRoot, 'dist', 'web', 'index.js'),
48
- },
49
- dedupe: ['react', 'react-dom'],
50
- },
36
+ configFile: join(shellRoot, 'vite.config.ts'),
37
+ // The integrated middleware already owns /node, /v1, and /__crtr. The
38
+ // shell config uses this mode to omit its standalone-server proxies.
39
+ mode: 'middleware',
51
40
  server: {
52
41
  middlewareMode: true,
53
42
  // Share our HTTP server for HMR; server.ts's upgrade handler hands every
@@ -100,7 +100,7 @@ const webServeLeaf = defineLeaf({
100
100
  const open = input['open'] ?? false;
101
101
  const dev = input['dev'] ?? false;
102
102
  const server = await startWebServer({ port, host, token, dev });
103
- const nodeUrlShape = `${server.url}/node/<id>`;
103
+ const nodeUrlShape = `${server.url}/nodes/<id>`;
104
104
  const browserUrl = token !== undefined ? `${server.url}?token=${encodeURIComponent(token)}` : server.url;
105
105
  process.stdout.write(`crtr surface web serving on ${server.url}${dev ? ' (--dev: Vite middleware)' : ''}\n` +
106
106
  ` open a node: ${nodeUrlShape}\n` +
@@ -13,7 +13,8 @@ import { randomBytes, randomUUID } from 'node:crypto';
13
13
  import { tmuxServerReachable } from '../../core/spawn.js';
14
14
  import { BrokerClient, BrokerUnavailableError } from '../../core/broker-client/index.js';
15
15
  import { markStopSignal } from '../../core/runtime/stop-signals.js';
16
- import { validateDeck, resolveDeckBodyPaths, notifyDeck, atomicWriteJson, submitDeck, submitReview, display, } from '@crouton-kit/humanloop';
16
+ import { validateDeck, resolveDeckBodyPaths, notifyDeck, atomicWriteJson, submitDeck, submitReview, } from '@crouton-kit/humanloop';
17
+ import { display } from '../../core/termrender/display.js';
17
18
  import { BODY_PATH_CONTRACT, DECK_SCHEMA_HINT, registerCrouterRoot, resolveMaxPanes } from './shared.js';
18
19
  import { inboxOpenInstruction, inboxPopupHint } from '../../core/keybindings/index.js';
19
20
  /** The asking node's id, or null when run from a bare shell (no parent to route to). */
@@ -1,19 +1,19 @@
1
- import { join, resolve, isAbsolute } from 'node:path';
2
- import { renameSync } from 'node:fs';
1
+ import { join, resolve, isAbsolute, sep } from 'node:path';
2
+ import { renameSync, writeFileSync } from 'node:fs';
3
+ import { createHash } from 'node:crypto';
3
4
  import { defineLeaf } from '../../core/command.js';
4
- import { CrtrError, notFound, usage, general, network } from '../../core/errors.js';
5
+ import { notFound, usage, general, network } from '../../core/errors.js';
5
6
  import { findMarketplaceByName, findPluginByName, listAllPlugins } from '../../core/resolver.js';
6
7
  import { pluginsDir, ensureProjectScopeRoot, userScopeRoot, resolveScopeArg, projectScopeRoot, requireScopeRoot, } from '../../core/scope.js';
7
8
  import { updateConfig, updateState, ensureScopeInitialized } from '../../core/config.js';
8
9
  import { pathExists, ensureDir, removePath, nowIso, linkOrCopy, isSymlink, atomicWriteJson } from '../../core/fs-utils.js';
9
10
  import { clone, pull, deriveNameFromUrl, currentSha, isGitRepo } from '../../core/git.js';
10
- import { pluginManifestPath, readMarketplaceManifest, readPluginManifest } from '../../core/manifest.js';
11
+ import { readMarketplaceManifest, readPluginManifest } from '../../core/manifest.js';
11
12
  import { validatePluginCommands, validateEffectiveCommandPlugins } from '../../core/command-plugins/discovery.js';
12
13
  import { normalizeEndpoint, validateEndpoint, validateHttpPluginTransport } from '../../core/command-plugins/endpoint.js';
13
- import { resolveHttpPluginManifestStore, writeHttpPluginManifest } from '../../core/command-plugins/store.js';
14
- import { fetchHttpPluginManifest, refreshHttpPlugin } from '../../core/command-plugins/transport/http-fetch.js';
14
+ import { validatePluginBundle } from '../../core/command-plugins/bundle.js';
15
+ import { fetchHttpPluginBundle } from '../../core/command-plugins/transport/http-fetch.js';
15
16
  import { SUBTREE_NAMES } from '../../build-root.js';
16
- import { ExitCode } from '../../types.js';
17
17
  import { isGitUrl, setPluginEnabled, resolveInstallScope } from './shared.js';
18
18
  /** Validate a just-installed/updated plugin's command manifest, if it declares
19
19
  * one. Returns undefined for plugins without a `commands` pointer. Never
@@ -32,66 +32,137 @@ function commandReport(name, scope) {
32
32
  return { mounts: v.contributions.map((c) => c.node.name), issues: v.issues };
33
33
  }
34
34
  const PLUGIN_NAME_RE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
35
- function unsafeHttpManifestPath(name, commands) {
36
- throw new CrtrError('command_path_unsafe', `HTTP plugin "${name}" commands pointer escapes the plugin root or is not a regular file`, ExitCode.USAGE, { received: commands, field: 'commands', next: 'Fix plugin.json.commands.' });
37
- }
38
35
  function validateHttpInstall(name, endpoint, authEnv) {
39
- if (typeof name !== 'string' || !PLUGIN_NAME_RE.test(name)) {
36
+ if (typeof name !== 'string' || !PLUGIN_NAME_RE.test(name))
40
37
  throw usage('invalid --name: expected a lowercase kebab-case plugin handle');
41
- }
42
38
  const endpointValidation = validateEndpoint(endpoint);
43
- if (!endpointValidation.isValid) {
39
+ if (!endpointValidation.isValid)
44
40
  throw usage(`invalid --endpoint: ${endpointValidation.errors.join('; ')}`);
45
- }
46
41
  const normalizedEndpoint = normalizeEndpoint(endpoint);
47
- const transportValidation = validateHttpPluginTransport({
48
- kind: 'http',
49
- endpoint: normalizedEndpoint,
50
- ...(authEnv !== undefined ? { authEnv } : {}),
51
- });
52
- if (transportValidation.transport === undefined) {
42
+ const transportValidation = validateHttpPluginTransport({ kind: 'http', endpoint: normalizedEndpoint, ...(authEnv !== undefined ? { authEnv } : {}) });
43
+ if (transportValidation.transport === undefined)
53
44
  throw usage(`invalid HTTP plugin transport: ${transportValidation.errors.join('; ')}`);
54
- }
55
45
  return transportValidation.transport;
56
46
  }
57
- async function installHttpPlugin(name, endpoint, authEnv, scope) {
58
- const transport = validateHttpInstall(name, endpoint, authEnv);
59
- const scopeRootPath = scope === 'project' ? ensureProjectScopeRoot() : userScopeRoot();
60
- const root = join(scopeRootPath, 'plugins', name);
61
- if (pathExists(root) && readPluginManifest(root)?.transport?.kind !== 'http') {
62
- throw general(`plugin "${name}" already exists at ${root} and is not an HTTP-transport plugin`);
47
+ function invocationTransport(bundle) {
48
+ return { kind: 'http', endpoint: new URL(bundle.endpoint).origin, ...(bundle.authEnv !== undefined ? { authEnv: bundle.authEnv } : {}) };
49
+ }
50
+ function stagedPath(stagingRoot, relative) {
51
+ const path = resolve(stagingRoot, relative);
52
+ if (path === stagingRoot || !path.startsWith(stagingRoot + sep)) {
53
+ throw general(`bundle member path escapes the staging directory: ${relative}`);
63
54
  }
64
- if (pathExists(root) && resolveHttpPluginManifestStore({ root, commands: 'commands.json' }) === null) {
65
- unsafeHttpManifestPath(name, 'commands.json');
55
+ return path;
56
+ }
57
+ function writeStagedBundle(stagingRoot, manifest, bundle) {
58
+ atomicWriteJson(stagedPath(stagingRoot, '.crouter-plugin/plugin.json'), manifest);
59
+ for (const directory of bundle.directories)
60
+ ensureDir(stagedPath(stagingRoot, directory.path));
61
+ writeFileSync(stagedPath(stagingRoot, 'commands.json'), bundle.commandsBytes);
62
+ for (const document of bundle.memory) {
63
+ const target = stagedPath(stagingRoot, document.path);
64
+ ensureDir(resolve(target, '..'));
65
+ writeFileSync(target, document.bytes);
66
66
  }
67
- const fetched = await fetchHttpPluginManifest({
68
- name,
69
- endpoint: transport.endpoint,
70
- ...(transport.authEnv !== undefined ? { authEnv: transport.authEnv } : {}),
71
- });
67
+ }
68
+ function validateStagedBundle(name, scope, root, manifest) {
69
+ const staged = { name, scope, root, manifest, enabled: true };
70
+ const validation = validatePluginCommands(staged, new Set(SUBTREE_NAMES));
71
+ if (validation.issues.length > 0) {
72
+ throw general(`bundle staged package for "${name}" is invalid`, { issues: validation.issues });
73
+ }
74
+ }
75
+ function isSourceInstalled(plugin) {
76
+ return plugin.sourceMarketplace !== undefined || isGitRepo(plugin.root) || isSymlink(plugin.root);
77
+ }
78
+ function rejectLegacyHttpPlugin(name) {
79
+ throw general(`HTTP plugin "${name}" uses the removed commands-only endpoint format`, { next: `Run \`crtr pkg plugin remove ${name}\`, then reinstall it with \`crtr pkg plugin install --name ${name} --endpoint <archive-url>\`.` });
80
+ }
81
+ function existingBundleRoot(name, scope, root) {
82
+ if (!pathExists(root))
83
+ return undefined;
84
+ const existing = findPluginByName(name, scope);
85
+ if (existing?.manifest.bundle !== undefined)
86
+ return existing;
87
+ if (existing?.manifest.transport?.kind === 'http' && existing !== undefined && !isSourceInstalled(existing))
88
+ rejectLegacyHttpPlugin(name);
89
+ throw general(`plugin "${name}" already exists at ${root}; run \`crtr pkg plugin remove ${name}\` before installing the archive plugin.`);
90
+ }
91
+ async function replaceBundlePlugin(name, bundleSource, scope, options) {
92
+ const fetchTransport = validateHttpInstall(name, bundleSource.endpoint, bundleSource.authEnv);
93
+ const bundle = { endpoint: fetchTransport.endpoint, ...(fetchTransport.authEnv !== undefined ? { authEnv: fetchTransport.authEnv } : {}) };
94
+ const transport = invocationTransport(bundle);
95
+ const scopeRootPath = scope === 'project' ? ensureProjectScopeRoot() : userScopeRoot();
96
+ const root = join(scopeRootPath, 'plugins', name);
97
+ existingBundleRoot(name, scope, root);
98
+ const fetched = await fetchHttpPluginBundle({ name, endpoint: bundle.endpoint, ...(bundle.authEnv !== undefined ? { authEnv: bundle.authEnv } : {}) });
72
99
  if (fetched.status !== 200) {
73
- const message = `HTTP plugin install failed for "${name}": ${fetched.message}`;
100
+ const message = `HTTP plugin bundle fetch failed for "${name}": ${fetched.message}`;
74
101
  if (fetched.status === 'auth_env_missing')
75
102
  throw usage(message);
76
103
  if (fetched.status === 'cli_unreachable')
77
104
  throw network(message);
78
105
  throw general(message);
79
106
  }
80
- ensureScopeInitialized(scope, scopeRootPath);
81
- atomicWriteJson(pluginManifestPath(root), {
107
+ const validated = await validatePluginBundle(fetched.raw, { reservedCoreNames: new Set(SUBTREE_NAMES) });
108
+ if (validated.bundle === undefined)
109
+ throw general(`HTTP plugin bundle for "${name}" is invalid`, { issues: validated.issues });
110
+ const version = createHash('sha256').update(fetched.raw).digest('hex').slice(0, 12);
111
+ const manifest = {
82
112
  name,
113
+ version,
83
114
  commands: 'commands.json',
84
115
  transport,
85
- });
86
- const store = resolveHttpPluginManifestStore({ root, commands: 'commands.json' });
87
- if (store === null)
88
- unsafeHttpManifestPath(name, 'commands.json');
89
- writeHttpPluginManifest(store, fetched.raw);
116
+ bundle,
117
+ };
118
+ const tmpRoot = join(scopeRootPath, 'tmp');
119
+ const staging = join(tmpRoot, `${name}.${process.pid}`);
120
+ const previous = `${staging}.prev`;
121
+ if (pathExists(previous))
122
+ throw general(`cannot replace plugin "${name}": prior interrupted replacement remains at ${previous}`);
123
+ ensureDir(tmpRoot);
124
+ ensureDir(join(scopeRootPath, 'plugins'));
125
+ removePath(staging);
126
+ try {
127
+ ensureDir(staging);
128
+ writeStagedBundle(staging, manifest, validated.bundle);
129
+ validateStagedBundle(name, scope, staging, manifest);
130
+ let movedPrevious = false;
131
+ if (pathExists(root)) {
132
+ renameSync(root, previous);
133
+ movedPrevious = true;
134
+ }
135
+ try {
136
+ renameSync(staging, root);
137
+ }
138
+ catch (error) {
139
+ if (movedPrevious)
140
+ renameSync(previous, root);
141
+ throw error;
142
+ }
143
+ if (movedPrevious)
144
+ removePath(previous);
145
+ }
146
+ finally {
147
+ removePath(staging);
148
+ }
149
+ ensureScopeInitialized(scope, scopeRootPath);
90
150
  updateConfig(scope, (cfg) => {
91
- cfg.plugins[name] = { enabled: true };
151
+ cfg.plugins[name] = { enabled: options.enable ? true : (cfg.plugins[name]?.enabled ?? true), version };
152
+ });
153
+ updateState(scope, (state) => {
154
+ if (state.plugins[name] === undefined)
155
+ state.plugins[name] = {};
156
+ state.plugins[name].last_updated = nowIso();
92
157
  });
93
158
  const commands = commandReport(name, scope);
94
- return { name, scope, path: root, transport: 'http', ...(commands !== undefined ? { commands } : {}) };
159
+ if (commands === undefined)
160
+ throw general(`bundle plugin "${name}" has no staged command report`);
161
+ return { name, scope, path: root, transport: 'http', version, docs: validated.bundle.memory.length, commands };
162
+ }
163
+ async function installHttpPlugin(name, endpoint, authEnv, scope) {
164
+ const source = validateHttpInstall(name, endpoint, authEnv);
165
+ return replaceBundlePlugin(name, { endpoint: source.endpoint, ...(source.authEnv !== undefined ? { authEnv: source.authEnv } : {}) }, scope, { enable: true });
95
166
  }
96
167
  function classifyInstallRef(raw) {
97
168
  if (raw.startsWith('./') || raw.startsWith('../') || raw.startsWith('/')) {
@@ -269,22 +340,13 @@ export function installFromMarketplace(ref, scope) {
269
340
  return { name: ref.plugin, scope, path: destPluginDir };
270
341
  }
271
342
  async function updateOnePlugin(plugin, marketplaceCache, opts) {
272
- if (plugin.manifest.transport?.kind === 'http') {
273
- const commandsPath = plugin.manifest.commands;
274
- if (typeof commandsPath !== 'string' || commandsPath.length === 0) {
275
- throw usage(`HTTP plugin "${plugin.name}" has no commands path in plugin.json; fix the manifest before updating.`);
276
- }
277
- const store = resolveHttpPluginManifestStore({ root: plugin.root, commands: commandsPath });
278
- if (store === null)
279
- unsafeHttpManifestPath(plugin.name, commandsPath);
280
- await refreshHttpPlugin({
281
- name: plugin.name,
282
- endpoint: plugin.manifest.transport.endpoint,
283
- ...(plugin.manifest.transport.authEnv !== undefined ? { authEnv: plugin.manifest.transport.authEnv } : {}),
284
- }, store);
285
- const commands = commandReport(plugin.name, plugin.scope);
286
- return { name: plugin.name, transport: 'http', updated: true, ...(commands !== undefined ? { commands } : {}) };
343
+ const sourceInstalled = isSourceInstalled(plugin);
344
+ if (!sourceInstalled && plugin.manifest.bundle !== undefined) {
345
+ const replaced = await replaceBundlePlugin(plugin.name, plugin.manifest.bundle, plugin.scope, { enable: plugin.enabled });
346
+ return { name: plugin.name, transport: 'http', updated: true, version: replaced.version, docs: replaced.docs, commands: replaced.commands };
287
347
  }
348
+ if (plugin.manifest.transport?.kind === 'http' && !sourceInstalled)
349
+ rejectLegacyHttpPlugin(plugin.name);
288
350
  let contentUpdate = { updated: false, sha: currentSha(plugin.root) ?? '' };
289
351
  let marketplaceVersion;
290
352
  if (plugin.sourceMarketplace !== undefined) {
@@ -309,7 +371,7 @@ async function updateOnePlugin(plugin, marketplaceCache, opts) {
309
371
  const commands = commandReport(plugin.name, plugin.scope);
310
372
  return {
311
373
  name: plugin.name,
312
- ...(plugin.manifest.transport?.kind === 'exec' ? { transport: 'exec' } : {}),
374
+ ...(plugin.manifest.transport !== undefined ? { transport: plugin.manifest.transport.kind } : {}),
313
375
  updated: contentUpdate.updated,
314
376
  sha: contentUpdate.sha,
315
377
  ...(commands !== undefined ? { commands } : {}),
@@ -318,13 +380,13 @@ async function updateOnePlugin(plugin, marketplaceCache, opts) {
318
380
  export const pluginInstall = defineLeaf({
319
381
  name: 'install',
320
382
  description: 'install from a ref or HTTP endpoint',
321
- whenToUse: 'installing one plugin from a marketplace ref, git URL, local path, or an HTTP command-manifest endpoint',
383
+ whenToUse: 'installing one plugin from a marketplace ref, git URL, local path, or an HTTP directory-archive endpoint',
322
384
  help: {
323
385
  name: 'pkg plugin install',
324
386
  summary: 'install a plugin from a ref or HTTP endpoint',
325
387
  params: [
326
388
  { kind: 'positional', name: 'install-ref', type: 'string', required: false, constraint: 'Plugin install ref. Provide exactly one of INSTALL-REF or --endpoint. Accepted ref forms: marketplace/plugin; git URL starting https://, ssh://, git@, file://, or ending .git; local path starting ./, ../, or /.' },
327
- { kind: 'flag', name: 'endpoint', type: 'string', required: false, constraint: 'HTTP plugin endpoint. Provide exactly one of --endpoint or INSTALL-REF. Must be an absolute https: URL; http: is permitted only for localhost, 127.0.0.1, [::1], ::1, or host.docker.internal. Credentials and fragments are rejected.' },
389
+ { kind: 'flag', name: 'endpoint', type: 'string', required: false, constraint: 'HTTP plugin directory-archive endpoint. Provide exactly one of --endpoint or INSTALL-REF. Must be an absolute https: URL; http: is permitted only for localhost, 127.0.0.1, [::1], ::1, or host.docker.internal. Credentials and fragments are rejected.' },
328
390
  { kind: 'flag', name: 'name', type: 'string', required: false, constraint: 'Required with --endpoint and rejected without it. Lowercase kebab-case plugin handle.' },
329
391
  { kind: 'flag', name: 'auth-env', type: 'string', required: false, constraint: 'Optional environment variable holding the HTTP bearer token. Accepted only with --endpoint; its value is never stored.' },
330
392
  { kind: 'flag', name: 'scope', type: 'enum', choices: ['user', 'project'], required: false, constraint: 'Default: project if available, else user.' },
@@ -335,12 +397,14 @@ export const pluginInstall = defineLeaf({
335
397
  { name: 'scope', type: 'string', required: true, constraint: 'Scope the plugin was installed into.' },
336
398
  { name: 'path', type: 'string', required: true, constraint: 'Absolute path to the installed plugin directory.' },
337
399
  { name: 'transport', type: 'string', required: false, constraint: 'exec or http when the plugin declares commands. Always http for an --endpoint install.' },
400
+ { name: 'version', type: 'string', required: false, constraint: 'First 12 hex characters of the fetched archive SHA-256 for an --endpoint install.' },
401
+ { name: 'docs', type: 'integer', required: false, constraint: 'Memory document count written from an --endpoint archive.' },
338
402
  { name: 'commands', type: 'object', required: false, constraint: 'Present only when the plugin declares a command manifest. {mounts: string[] (accepted top-level command names now live for the next invocation), issues: object[] (typed validation issues that rejected a contribution — {code, path?, message, received, expected, next})}. Validated statically; commands are never executed.' },
339
403
  ],
340
404
  outputKind: 'object',
341
405
  effects: [
342
406
  'A ref install clones, links, or copies the plugin into the scope plugins directory and registers it with enabled=true. Marketplace installs refresh the source marketplace before resolving the plugin entry.',
343
- 'An --endpoint install validates inputs, fetches the command manifest before writing, then writes plugin.json and commands.json and enables the plugin. Reinstalling the same HTTP plugin name replaces its stored manifest and metadata; a fetch failure writes nothing.',
407
+ 'An --endpoint install fetches and validates one authenticated uncompressed tar archive before writing. It replaces the complete plugin directory with synthesized provenance and invocation metadata, commands.json, and memory docs; a fetch or validation failure preserves the prior package.',
344
408
  'If the plugin declares a command manifest, its commands go live on the next crtr invocation. Exec commands run trusted local code only when explicitly invoked; HTTP commands call their declared endpoint only when explicitly invoked.',
345
409
  ],
346
410
  },
@@ -478,7 +542,7 @@ export const pluginDisable = defineLeaf({
478
542
  export const pluginUpdate = defineLeaf({
479
543
  name: 'update',
480
544
  description: 'update installed plugin content',
481
- whenToUse: 'refreshing one named plugin or all installed plugins: HTTP plugins re-fetch their command manifest, while local plugins retain their git update behavior',
545
+ whenToUse: 'refreshing one named plugin or all installed plugins: archive plugins replace commands and memory together, while source-installed plugins retain their source update behavior',
482
546
  help: {
483
547
  name: 'pkg plugin update',
484
548
  summary: 'refresh one or all installed plugins',
@@ -486,10 +550,10 @@ export const pluginUpdate = defineLeaf({
486
550
  { kind: 'flag', name: 'name', type: 'string', required: false, constraint: 'Plugin name to update. Omit to update all installed plugins.' },
487
551
  ],
488
552
  output: [
489
- { name: 'updated', type: 'object[]', required: true, constraint: 'One entry per plugin processed: {name, transport?, updated, sha?, commands?}. HTTP plugins report transport=http and replace commands.json after an authenticated refetch; sha is present for git updates. commands is present only for plugins declaring a command manifest: {mounts: string[] (accepted top-level command names), issues: object[] ({code, path?, message, received, expected, next})} — validated statically, never executing commands.' },
553
+ { name: 'updated', type: 'object[]', required: true, constraint: 'One entry per plugin processed: {name, transport?, updated, sha?, version?, docs?, commands?, error?}. Archive plugins report transport=http and replace their complete directory after an authenticated refetch; version is the archive content identifier and docs is the written memory-document count. sha is present for git updates. A bulk update reports and skips a failed archive plugin. commands is present for validated command plugins: {mounts: string[], issues: object[]}.' },
490
554
  ],
491
555
  outputKind: 'object',
492
- effects: ['HTTP plugins unconditionally re-fetch and replace commands.json; a fetch failure exits nonzero and preserves prior bytes. Other plugins retain their git pull, version, and last_updated behavior.'],
556
+ effects: ['Archive plugins unconditionally re-fetch, validate, and replace their complete package; a named failure exits nonzero and preserves the prior package. Bulk update reports a failed archive plugin and continues. Source-installed plugins retain their source update, version, and last_updated behavior.'],
493
557
  },
494
558
  run: async (input) => {
495
559
  const name = input['name'];
@@ -503,8 +567,19 @@ export const pluginUpdate = defineLeaf({
503
567
  }
504
568
  const targets = listAllPlugins().filter((p) => p.scope !== 'builtin');
505
569
  const results = [];
506
- for (const plugin of targets)
507
- results.push(await updateOnePlugin(plugin, marketplaceCache, { strict: false }));
570
+ for (const plugin of targets) {
571
+ try {
572
+ results.push(await updateOnePlugin(plugin, marketplaceCache, { strict: false }));
573
+ }
574
+ catch (error) {
575
+ results.push({
576
+ name: plugin.name,
577
+ ...(plugin.manifest.transport !== undefined ? { transport: plugin.manifest.transport.kind } : {}),
578
+ updated: false,
579
+ error: error instanceof Error ? error.message : String(error),
580
+ });
581
+ }
582
+ }
508
583
  return { updated: results };
509
584
  },
510
585
  });
@@ -20,8 +20,10 @@ test('setup keybinding draft edits, disables, resets, and discards transactional
20
20
  const draft = new SetupKeybindingsDraft({ 'crtr.attach.graph.toggle': ['alt+x'] });
21
21
  assert.equal(draft.expression('crtr.attach.graph.toggle'), 'alt+x');
22
22
  assert.equal(draft.dirty, false);
23
- assert.equal(draft.edit('crtr.attach.graph.toggle', 'M-Y, ctrl+g').ok, true);
24
- assert.deepEqual(draft.overrides()['crtr.attach.graph.toggle'], ['alt+shift+y', 'ctrl+g']);
23
+ // M-U (not M-Y): alt+shift+y is now the crtr.attach.copy.message default, so
24
+ // the uppercase-normalization probe must use a gesture no catalog entry owns.
25
+ assert.equal(draft.edit('crtr.attach.graph.toggle', 'M-U, ctrl+g').ok, true);
26
+ assert.deepEqual(draft.overrides()['crtr.attach.graph.toggle'], ['alt+shift+u', 'ctrl+g']);
25
27
  assert.equal(draft.dirty, true);
26
28
  assert.equal(draft.unbind('crtr.attach.graph.toggle').ok, true);
27
29
  assert.deepEqual(draft.overrides()['crtr.attach.graph.toggle'], []);
@@ -10,7 +10,7 @@ import { readJsonIfExists } from '../../core/fs-utils.js';
10
10
  import { resolveBundledPiCliPath } from '../../core/runtime/pi-cli.js';
11
11
  import { installTmuxBindings } from '../../core/runtime/tmux-chrome.js';
12
12
  import { detectNerdFont, nerdFontInstallCommand, NERD_FONT_SELECT_HINT } from '../../core/runtime/nerd-font.js';
13
- import { globalHumanloopHealth, globalHumanloopInstallCommand } from '../../core/runtime/package-health.js';
13
+ import { ensureRenderer, isRendererReady } from '../../core/termrender/termrender.js';
14
14
  import { registerCrouterRoot } from '../human/shared.js';
15
15
  import { installFromMarketplace } from '../pkg/plugin-manage.js';
16
16
  import { findPluginByName } from '../../core/resolver.js';
@@ -468,17 +468,15 @@ function installPiPackage(source, piCliPath = resolveBundledPiCliPath()) {
468
468
  throw new Error(`pi install failed: ${source}`);
469
469
  }
470
470
  }
471
- /** Keep the PATH-global executable used by the tmux inbox binding at least as
472
- * new as crouter's bundled humanloop library. This is deliberately separate
473
- * from pi's companion package install: `hl` is a global npm binary, not a pi
474
- * package entry in settings.json. */
475
- function ensureGlobalHumanloop() {
476
- if (globalHumanloopHealth().current)
477
- return false;
478
- const command = globalHumanloopInstallCommand();
479
- process.stdout.write(`Updating global hl CLI: ${command}\n`);
480
- runShellCommand(command);
481
- return true;
471
+ /** Provision the pinned termrender venv — crouter's one remaining out-of-tree
472
+ * dependency. Best-effort: a renderer that will not provision (no `uv`, no
473
+ * network) reports as skipped and never fails setup. */
474
+ function ensureManagedRenderer() {
475
+ if (isRendererReady())
476
+ return 'current';
477
+ process.stdout.write('Provisioning the pinned termrender renderer…\n');
478
+ ensureRenderer();
479
+ return isRendererReady() ? 'provisioned' : 'unavailable';
482
480
  }
483
481
  export async function writeExaKey(exaKeyPath, exaKey) {
484
482
  await mkdir(dirname(exaKeyPath), { recursive: true });
@@ -572,10 +570,17 @@ export async function executeSetupSubmission(submission) {
572
570
  runShellCommand(plan.command);
573
571
  installedSystemDeps.push(plan.tool);
574
572
  }
575
- if (ensureGlobalHumanloop())
576
- installedPackages.push('global hl CLI');
577
- else
578
- skippedPackages.push('global hl CLI (current)');
573
+ switch (ensureManagedRenderer()) {
574
+ case 'provisioned':
575
+ installedPackages.push('termrender renderer');
576
+ break;
577
+ case 'current':
578
+ skippedPackages.push('termrender renderer (current)');
579
+ break;
580
+ case 'unavailable':
581
+ skippedPackages.push('termrender renderer (unavailable — install uv)');
582
+ break;
583
+ }
579
584
  const exaKeyWritten = submission.exaKey.trim() !== '';
580
585
  if (exaKeyWritten) {
581
586
  await writeExaKey(submission.exaKeyPath, submission.exaKey);
@@ -1,3 +1,11 @@
1
1
  /** Write `text` to the system clipboard. Returns true on success, false when no
2
2
  * clipboard tool is available or every attempt failed. Never throws. */
3
3
  export declare function writeClipboardText(text: string): boolean;
4
+ /** Write `text` to the system clipboard AND, inside tmux, to the tmux paste
5
+ * buffer via `tmux load-buffer -w -` (which makes tmux emit OSC 52 outward on
6
+ * the app's behalf). Never emits OSC 52 from this process. Never throws.
7
+ * Returns which operations succeeded. */
8
+ export declare function writeClipboardTextEverywhere(text: string): {
9
+ system: boolean;
10
+ tmux: boolean;
11
+ };
@@ -37,3 +37,21 @@ export function writeClipboardText(text) {
37
37
  }
38
38
  return false;
39
39
  }
40
+ /** Write `text` to the system clipboard AND, inside tmux, to the tmux paste
41
+ * buffer via `tmux load-buffer -w -` (which makes tmux emit OSC 52 outward on
42
+ * the app's behalf). Never emits OSC 52 from this process. Never throws.
43
+ * Returns which operations succeeded. */
44
+ export function writeClipboardTextEverywhere(text) {
45
+ const system = writeClipboardText(text);
46
+ let tmux = false;
47
+ if (process.env.TMUX !== undefined) {
48
+ const result = spawnSync('tmux', ['load-buffer', '-w', '-'], {
49
+ input: text,
50
+ timeout: SPAWN_TIMEOUT_MS,
51
+ encoding: 'utf-8',
52
+ });
53
+ // Success if no error and exit status 0.
54
+ tmux = !result.error && result.status === 0;
55
+ }
56
+ return { system, tmux };
57
+ }
@@ -25,9 +25,7 @@ export interface ContributorIssue extends CommandManifestIssue {
25
25
  contributor: CommandContributorRef;
26
26
  }
27
27
  /** Invocation-local registry snapshot after collision resolution: the accepted
28
- * contributions and every discovered issue. The amended spec has no freshness
29
- * machinery, so there is no owner-routing map — an unknown-token miss hydrates
30
- * ALL absent-store registrations once (§5.1), never a single known owner. */
28
+ * contributions and every discovered issue. */
31
29
  export interface CommandRegistrySnapshot {
32
30
  /** Accepted top-level contributions, deterministically ordered by
33
31
  * contributor name. */
@@ -0,0 +1,29 @@
1
+ import { type ValidatedCommandManifest } from '../command-manifests/manifest.js';
2
+ export interface BundleIssue {
3
+ code: string;
4
+ path?: string;
5
+ message: string;
6
+ received: string;
7
+ expected: string;
8
+ next: string;
9
+ }
10
+ export interface ValidatedBundle {
11
+ bundleVersion: 1;
12
+ commands: ValidatedCommandManifest;
13
+ commandsBytes: Uint8Array;
14
+ directories: ReadonlyArray<{
15
+ path: string;
16
+ }>;
17
+ memory: ReadonlyArray<{
18
+ path: string;
19
+ bytes: Uint8Array;
20
+ }>;
21
+ }
22
+ export interface PluginBundleValidation {
23
+ bundle?: ValidatedBundle;
24
+ issues: BundleIssue[];
25
+ }
26
+ /** Validate a Core-served plugin directory archive entirely in memory. */
27
+ export declare function validatePluginBundle(archive: Uint8Array, options: {
28
+ reservedCoreNames: ReadonlySet<string>;
29
+ }): Promise<PluginBundleValidation>;