@north-light/api 0.3.418 → 0.3.420

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.
@@ -132,4 +132,11 @@ export interface ReconcilePackagesDTO {
132
132
  path: string;
133
133
  error: string;
134
134
  }>;
135
+ /** plugin directories with a damaged manifest or command file, moved aside to `<scope>/quarantine/plugins/` */
136
+ quarantined?: Array<{
137
+ name: string;
138
+ file: string;
139
+ reason: string;
140
+ movedTo?: string;
141
+ }>;
135
142
  }
@@ -298,6 +298,7 @@ export interface NodeSnapshotDTO {
298
298
  followUp: string[];
299
299
  };
300
300
  toolGroupSummaries?: Record<string, unknown>;
301
+ turnReplies?: Record<string, unknown>;
301
302
  workingActivity?: string;
302
303
  };
303
304
  commands: {
@@ -103,6 +103,9 @@ export declare function isInstalledGeneration(name: string, scope: Scope, root:
103
103
  export declare function recordCheckedGeneration(scope: Scope, name: string, etag: string | undefined, options: {
104
104
  swapped: boolean;
105
105
  }): void;
106
+ /** Every file lands by temp file plus rename, fsynced, so a crash mid-write leaves the previous bytes or
107
+ * the whole new ones — never a 0-byte `plugin.json` or `commands.json` that crashes the next boot. */
108
+ export declare function writeStagedBundle(stagingRoot: string, manifest: PluginManifest, bundle: ValidatedBundle): void;
106
109
  export interface InstallBundleArchiveOptions<M> {
107
110
  scope: Scope;
108
111
  /** The scope root whose `tmp/` holds staging, `.prev` and the swap lock. */
@@ -1,11 +1,11 @@
1
1
  import { Parser } from 'tar';
2
2
  import { createHash } from 'node:crypto';
3
- import { chmodSync, renameSync, writeFileSync } from 'node:fs';
3
+ import { chmodSync, renameSync } from 'node:fs';
4
4
  import { dirname, join, resolve, sep } from 'node:path';
5
5
  import { validateCommandManifest } from '../../api/command-manifest/manifest.js';
6
6
  import { ensureScopeInitialized, invalidPluginKindsReasons, readConfig, updateConfig, updateState } from '../config.js';
7
7
  import { general } from '../errors.js';
8
- import { atomicWriteJson, ensureDir, nowIso, pathExists, removePath } from '../fs-utils.js';
8
+ import { atomicWriteJson, atomicWriteText, ensureDir, nowIso, pathExists, removePath, syncDirectory, syncTree } from '../fs-utils.js';
9
9
  import { invalidPageComponentsReasons, normalizePageComponents } from '../human/page-catalog.js';
10
10
  import { validateMemoryExtensionDeclarations } from '../plugin-extensions.js';
11
11
  import { validateHttpPluginTransport } from './endpoint.js';
@@ -481,25 +481,24 @@ function stagedPath(stagingRoot, relative) {
481
481
  }
482
482
  return path;
483
483
  }
484
- function writeStagedBundle(stagingRoot, manifest, bundle) {
485
- atomicWriteJson(stagedPath(stagingRoot, '.crouter-plugin/plugin.json'), manifest);
484
+ /** Every file lands by temp file plus rename, fsynced, so a crash mid-write leaves the previous bytes or
485
+ * the whole new ones — never a 0-byte `plugin.json` or `commands.json` that crashes the next boot. */
486
+ export function writeStagedBundle(stagingRoot, manifest, bundle) {
487
+ const write = (path, content, mode = 0o644) => atomicWriteText(path, content, { mode, durable: true });
488
+ atomicWriteJson(stagedPath(stagingRoot, '.crouter-plugin/plugin.json'), manifest, { durable: true });
486
489
  for (const directory of bundle.directories)
487
490
  ensureDir(stagedPath(stagingRoot, directory.path));
488
- writeFileSync(stagedPath(stagingRoot, 'commands.json'), bundle.commandsBytes);
489
- for (const document of bundle.memory) {
490
- const target = stagedPath(stagingRoot, document.path);
491
- ensureDir(resolve(target, '..'));
492
- writeFileSync(target, document.bytes);
493
- }
491
+ write(stagedPath(stagingRoot, 'commands.json'), bundle.commandsBytes);
492
+ for (const document of bundle.memory)
493
+ write(stagedPath(stagingRoot, document.path), document.bytes);
494
494
  if (bundle.hooks !== undefined) {
495
- writeFileSync(stagedPath(stagingRoot, 'hooks.json'), bundle.hooks.manifest);
496
- for (const file of bundle.hooks.files) {
497
- const target = stagedPath(stagingRoot, file.path);
498
- ensureDir(resolve(target, '..'));
499
- writeFileSync(target, file.bytes);
500
- }
495
+ write(stagedPath(stagingRoot, 'hooks.json'), bundle.hooks.manifest);
496
+ for (const file of bundle.hooks.files)
497
+ write(stagedPath(stagingRoot, file.path), file.bytes);
501
498
  chmodSync(stagedPath(stagingRoot, 'hooks/dispatch'), 0o755);
502
499
  }
500
+ // The staged tree reaches disk before the directory rename publishes it.
501
+ syncTree(stagingRoot);
503
502
  }
504
503
  /**
505
504
  * Validate `raw`, stage it beside the package root, swap it in, then record
@@ -565,6 +564,7 @@ export async function installBundleArchive(name, raw, options) {
565
564
  renameSync(previous, root);
566
565
  throw error;
567
566
  }
567
+ syncDirectory(dirname(root));
568
568
  if (movedPrevious)
569
569
  removePath(previous);
570
570
  }
@@ -833,6 +833,7 @@ function mergeConfig(partial) {
833
833
  const fold_finished_tools = typeof partial.fold_finished_tools === 'boolean' ? partial.fold_finished_tools : defaults.fold_finished_tools;
834
834
  const summarize_tool_calls = typeof partial.summarize_tool_calls === 'boolean' ? partial.summarize_tool_calls : defaults.summarize_tool_calls;
835
835
  const detailed_tool_recaps = typeof partial.detailed_tool_recaps === 'boolean' ? partial.detailed_tool_recaps : defaults.detailed_tool_recaps;
836
+ const consolidate_replies = typeof partial.consolidate_replies === 'boolean' ? partial.consolidate_replies : defaults.consolidate_replies;
836
837
  const keybindings = normalizeKeybindings(partial.keybindings);
837
838
  const tmux_passthrough = normalizeTmuxPassthrough(partial.tmux_passthrough);
838
839
  const modelLadders = mergeModelLadders(partial.modelLadders);
@@ -856,7 +857,7 @@ function mergeConfig(partial) {
856
857
  const spawnEnv = mergeSpawnEnv(partial.spawnEnv, defaults.spawnEnv?.allow);
857
858
  const bin = normalizeBin(partial.bin);
858
859
  const humanActions = normalizeHumanActions(partial.humanActions);
859
- return { bin, humanActions, schema_version, marketplaces, plugins, auto_update, max_panes_per_window, auto_open_child_viewers, brokerThresholds, lifecycle, completion_bell, working_gerunds, whip_mode, page_components, page_surface, whip_messages, whip_message_mode, mouse_mode_default, live_cycles, condensed_history, bash_tool_purpose, fold_finished_tools, summarize_tool_calls, detailed_tool_recaps, keybindings, tmux_passthrough, modelLadders, providerOptions, ...(Object.keys(modelRoutes).length > 0 ? { modelRoutes } : {}), ...(Object.keys(routes.legacy).length > 0 ? { legacyModelRoutes: routes.legacy } : {}), ...(modelRouting !== undefined ? { modelRouting } : {}), kinds, remoteCanvas, api, ...(paths ? { paths } : {}), ...(store ? { store } : {}), ...(runtime ? { runtime } : {}), spawnEnv };
860
+ return { bin, humanActions, schema_version, marketplaces, plugins, auto_update, max_panes_per_window, auto_open_child_viewers, brokerThresholds, lifecycle, completion_bell, working_gerunds, whip_mode, page_components, page_surface, whip_messages, whip_message_mode, mouse_mode_default, live_cycles, condensed_history, bash_tool_purpose, fold_finished_tools, summarize_tool_calls, detailed_tool_recaps, consolidate_replies, keybindings, tmux_passthrough, modelLadders, providerOptions, ...(Object.keys(modelRoutes).length > 0 ? { modelRoutes } : {}), ...(Object.keys(routes.legacy).length > 0 ? { legacyModelRoutes: routes.legacy } : {}), ...(modelRouting !== undefined ? { modelRouting } : {}), kinds, remoteCanvas, api, ...(paths ? { paths } : {}), ...(store ? { store } : {}), ...(runtime ? { runtime } : {}), spawnEnv };
860
861
  }
861
862
  /** Raw (un-defaulted) partial config for one scope, or null if the scope has
862
863
  * no root or no config.json. Used by `readMergedLaunchConfig` to layer
@@ -13,6 +13,10 @@ export interface AtomicWriteOptions {
13
13
  mode?: number;
14
14
  /** Require the parent to exist, rather than recreate a removed project root. */
15
15
  createParent?: boolean;
16
+ /** Fsync the temp file before the rename and the parent directory after it. Without this a power loss
17
+ * or a killed VM can persist the rename but not the data — the 0-byte file a plain
18
+ * temp-plus-rename still leaves behind. */
19
+ durable?: boolean;
16
20
  }
17
21
  export declare function atomicWriteJson(path: string, data: unknown, opts?: AtomicWriteOptions): void;
18
22
  /** Force a file's written bytes to stable storage. */
@@ -23,6 +27,9 @@ export declare function syncDirectory(path: string): void;
23
27
  * tmux inbox watcher, or crtrd serving a ticket read — sees either the old
24
28
  * bytes or the new ones and never a truncated file mid-write. */
25
29
  export declare function atomicWriteText(path: string, content: string | Uint8Array, opts?: AtomicWriteOptions): void;
30
+ /** Fsync every file and directory under `root` (and `root` itself), so a tree about to be renamed into place
31
+ * reaches stable storage before the rename can. */
32
+ export declare function syncTree(root: string): void;
26
33
  export declare function readJson<T = unknown>(path: string): T;
27
34
  export declare function readJsonIfExists<T = unknown>(path: string): T | null;
28
35
  /** Missing or corrupt reads as absent, so a crash artifact never throws. */
@@ -69,13 +69,29 @@ export function atomicWriteText(path, content, opts) {
69
69
  try {
70
70
  writeFileSync(temp, content, { mode });
71
71
  chmodSync(temp, mode);
72
+ if (opts?.durable === true)
73
+ syncFile(temp);
72
74
  renameSync(temp, path);
75
+ if (opts?.durable === true)
76
+ syncDirectory(dirname(path));
73
77
  }
74
78
  finally {
75
79
  if (existsSync(temp))
76
80
  rmSync(temp, { force: true });
77
81
  }
78
82
  }
83
+ /** Fsync every file and directory under `root` (and `root` itself), so a tree about to be renamed into place
84
+ * reaches stable storage before the rename can. */
85
+ export function syncTree(root) {
86
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
87
+ const child = join(root, entry.name);
88
+ if (entry.isDirectory())
89
+ syncTree(child);
90
+ else if (entry.isFile())
91
+ syncFile(child);
92
+ }
93
+ syncDirectory(root);
94
+ }
79
95
  export function readJson(path) {
80
96
  return JSON.parse(readFileSync(path, 'utf8'));
81
97
  }
@@ -1,6 +1,6 @@
1
1
  import type { BindingDefinition, SafetyRequirement } from './types.js';
2
2
  import type { AttachBindingId, AttachMenuBindingId } from './attach-control.js';
3
- type TmuxMenuAction = 'open' | 'attach' | 'show' | 'view-settings' | 'provider-options' | 'fold-tools' | 'metadata' | 'promote' | 'demote-mode' | 'resume' | 'fork' | 'demote' | 'make-resident' | 'detach' | 'close-subtree' | 'background-bash' | 'file-review' | 'pin' | 'reload-viewer' | 'recap' | 'context' | 'context-admin' | 'providers' | 'plugins' | 'analytics' | 'graph' | 'focus-manager' | 'copy-id' | 'issues';
3
+ type TmuxMenuAction = 'open' | 'attach' | 'show' | 'view-settings' | 'provider-options' | 'fold-tools' | 'show-working' | 'metadata' | 'promote' | 'demote-mode' | 'resume' | 'fork' | 'demote' | 'make-resident' | 'detach' | 'close-subtree' | 'background-bash' | 'file-review' | 'pin' | 'reload-viewer' | 'recap' | 'context' | 'context-admin' | 'providers' | 'plugins' | 'analytics' | 'graph' | 'focus-manager' | 'copy-id' | 'issues';
4
4
  type TmuxMenuEntryAction = Exclude<TmuxMenuAction, 'open' | 'attach'> | `issue.${'idea' | 'bug'}`;
5
5
  type GraphAction = 'down' | 'up' | 'first' | 'last' | 'collapse-or-manager' | 'expand-or-child' | 'focus' | 'revive' | 'focus-manager' | 'close-subtree' | 'dismiss';
6
6
  type BrowseAction = 'quit' | 'down' | 'up' | 'first' | 'last' | 'expand-or-child' | 'collapse-or-parent' | 'toggle-fold' | 'tab-next' | 'tab-previous' | `tab.${1 | 2 | 3 | 4 | 5}` | 'close-subtree' | 'confirm-close' | 'sort' | 'scope' | 'profile' | 'residents' | 'preview' | 'report' | 'pin' | 'search' | 'revive-now' | 'focus' | 'focus-background' | 'yank-id';
@@ -52,6 +52,7 @@ export const BINDING_CATALOG = Object.freeze([
52
52
  ...menuEntry('show', 'Inspect node', 'Open the focused node’s inspection menu.', ['i'], ['tmux.menu']),
53
53
  ...menuEntry('view-settings', 'Display settings', 'Open the viewer display-settings menu.', ['z'], ['tmux.menu']),
54
54
  ...menuEntry('fold-tools', 'Fold finished tool calls', 'Fold every finished tool call in the transcript to its single call line.', ['f'], ['tmux.menu.view-settings']),
55
+ ...menuEntry('show-working', 'Show working', 'With consolidated replies on, show or hide the messages and tool calls behind each reply.', ['w'], ['tmux.menu.view-settings']),
55
56
  ...menuEntry('metadata', 'Show metadata', 'Show the focused node’s metadata dossier.', ['m'], ['tmux.menu.show']),
56
57
  ...menuEntry('context', 'Browse context', 'Browse the focused node context.', ['c'], ['tmux.menu.show']),
57
58
  ...menuEntry('providers', 'Open settings', 'Open the crouter settings panel.', [','], ['tmux.menu']),
@@ -1,5 +1,20 @@
1
1
  import type { MarketplaceManifest, PluginManifest } from '../types.js';
2
2
  export declare function pluginManifestPath(pluginRoot: string): string;
3
3
  export declare function marketplaceManifestPath(mktRoot: string): string;
4
+ /** What a manifest file holds: `absent` (no file, or JSON `null`), `ok`, or `broken` —
5
+ * present but unreadable, not JSON (a 0-byte file from a write cut short), or not an object. */
6
+ export type ManifestRead<T> = {
7
+ status: 'absent';
8
+ } | {
9
+ status: 'ok';
10
+ manifest: T;
11
+ } | {
12
+ status: 'broken';
13
+ error: string;
14
+ };
15
+ export declare function readPluginManifestChecked(pluginRoot: string): ManifestRead<PluginManifest>;
16
+ /** The plugin's manifest, or null when it has none or it is unreadable: one damaged plugin
17
+ * directory must read as "not installed", never throw through whoever lists plugins. The daemon
18
+ * quarantines such a directory (`quarantineBrokenPlugins`); `readPluginManifestChecked` says why. */
4
19
  export declare function readPluginManifest(pluginRoot: string): PluginManifest | null;
5
20
  export declare function readMarketplaceManifest(mktRoot: string): MarketplaceManifest | null;
@@ -7,9 +7,31 @@ export function pluginManifestPath(pluginRoot) {
7
7
  export function marketplaceManifestPath(mktRoot) {
8
8
  return join(mktRoot, MARKETPLACE_MANIFEST_DIR, MARKETPLACE_MANIFEST_FILE);
9
9
  }
10
+ function readManifestChecked(path) {
11
+ let value;
12
+ try {
13
+ value = readJsonIfExists(path);
14
+ }
15
+ catch (error) {
16
+ return { status: 'broken', error: error instanceof Error ? error.message : String(error) };
17
+ }
18
+ if (value === null)
19
+ return { status: 'absent' };
20
+ if (typeof value !== 'object' || Array.isArray(value))
21
+ return { status: 'broken', error: 'manifest is not a JSON object' };
22
+ return { status: 'ok', manifest: value };
23
+ }
24
+ export function readPluginManifestChecked(pluginRoot) {
25
+ return readManifestChecked(pluginManifestPath(pluginRoot));
26
+ }
27
+ /** The plugin's manifest, or null when it has none or it is unreadable: one damaged plugin
28
+ * directory must read as "not installed", never throw through whoever lists plugins. The daemon
29
+ * quarantines such a directory (`quarantineBrokenPlugins`); `readPluginManifestChecked` says why. */
10
30
  export function readPluginManifest(pluginRoot) {
11
- return readJsonIfExists(pluginManifestPath(pluginRoot));
31
+ const read = readPluginManifestChecked(pluginRoot);
32
+ return read.status === 'ok' ? read.manifest : null;
12
33
  }
13
34
  export function readMarketplaceManifest(mktRoot) {
14
- return readJsonIfExists(marketplaceManifestPath(mktRoot));
35
+ const read = readManifestChecked(marketplaceManifestPath(mktRoot));
36
+ return read.status === 'ok' ? read.manifest : null;
15
37
  }
@@ -290,6 +290,21 @@ export declare const CONFIG_SETTINGS_COVERAGE: {
290
290
  setFromString: (config: ScopeConfig, raw: string) => string | null;
291
291
  };
292
292
  };
293
+ consolidate_replies: {
294
+ surface: "settings";
295
+ setting: {
296
+ key: "consolidate_replies";
297
+ label: string;
298
+ description: string;
299
+ valueConstraint: string;
300
+ applies: string;
301
+ group: "display";
302
+ order: number;
303
+ format: (config: ScopeConfig) => "on" | "off";
304
+ cycle: (config: ScopeConfig) => void;
305
+ setFromString: (config: ScopeConfig, raw: string) => string | null;
306
+ };
307
+ };
293
308
  keybindings: {
294
309
  surface: "specialized";
295
310
  owner: string;
@@ -391,6 +391,27 @@ export const CONFIG_SETTINGS_COVERAGE = {
391
391
  },
392
392
  },
393
393
  },
394
+ consolidate_replies: {
395
+ surface: 'settings',
396
+ setting: {
397
+ key: 'consolidate_replies',
398
+ label: 'Consolidate replies',
399
+ description: 'Experiment: show one rewritten agent reply per message you type, with the working folded behind it (/show-working).',
400
+ valueConstraint: 'true|false',
401
+ applies: 'Applies on the next attach; the broker reads it per turn.',
402
+ group: 'display',
403
+ order: 36.5,
404
+ format: (config) => config.consolidate_replies ? 'on' : 'off',
405
+ cycle: (config) => { config.consolidate_replies = !config.consolidate_replies; },
406
+ setFromString: (config, raw) => {
407
+ const value = booleanFromString(raw, 'consolidate_replies');
408
+ if (typeof value === 'string')
409
+ return value;
410
+ config.consolidate_replies = value;
411
+ return null;
412
+ },
413
+ },
414
+ },
394
415
  keybindings: { surface: 'specialized', owner: 'Keybindings tab' },
395
416
  tmux_passthrough: { surface: 'specialized', owner: 'Keybindings tab' },
396
417
  providerOptions: {
package/dist/types.d.ts CHANGED
@@ -341,6 +341,12 @@ export interface ScopeConfig {
341
341
  /** Show individual document reads and edited files in a folded tool recap.
342
342
  * Off shows the compact activity-count line instead. Default true. */
343
343
  detailed_tool_recaps: boolean;
344
+ /** Experiment: show one consolidated agent reply per message the person typed.
345
+ * The broker rewrites a turn's prose messages into one reply with a light
346
+ * model; the attach viewer folds the rest of the turn behind a working row
347
+ * (`/show-working`, Alt+C → z → w). Session and model context are untouched.
348
+ * Default false. */
349
+ consolidate_replies: boolean;
344
350
  keybindings: KeybindingOverrides;
345
351
  /** Attach-viewer actions whose effective gesture may replace an occupied tmux
346
352
  * root binding only while the active pane is an attach viewer. Stored by
package/dist/types.js CHANGED
@@ -100,6 +100,7 @@ export function defaultScopeConfig() {
100
100
  fold_finished_tools: true,
101
101
  summarize_tool_calls: true,
102
102
  detailed_tool_recaps: true,
103
+ consolidate_replies: false,
103
104
  keybindings: {},
104
105
  tmux_passthrough: [],
105
106
  modelLadders: defaultModelLaddersConfig(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/api",
3
- "version": "0.3.418",
3
+ "version": "0.3.420",
4
4
  "description": "Typed crtrd /v1 API contract — DTOs, route builders, the error contract, the CrtrClient, and the command-plugin manifest format. Zero runtime dependencies.",
5
5
  "type": "module",
6
6
  "main": "./dist/api/index.js",