@openagents-org/agent-launcher 0.2.165 → 0.2.167

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openagents-org/agent-launcher",
3
- "version": "0.2.165",
3
+ "version": "0.2.167",
4
4
  "description": "OpenAgents Launcher \u2014 install, configure, and run AI coding agents from your terminal",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -48,6 +48,8 @@ class AmpAdapter extends BaseAdapter {
48
48
  constructor(opts) {
49
49
  super(opts);
50
50
  this.disabledModules = opts.disabledModules || new Set();
51
+ // Pin the channel's decision log + glossary into the system prompt.
52
+ this._usesPinnedContext = true;
51
53
 
52
54
  // channel -> Amp thread id (for `amp threads continue <id>`)
53
55
  this._channelThreads = {};
@@ -181,6 +183,7 @@ class AmpAdapter extends BaseAdapter {
181
183
  mode: this._mode,
182
184
  disabledModules: this.disabledModules,
183
185
  browserEnabled,
186
+ ...this.pinnedPromptOpts(channelName),
184
187
  });
185
188
  }
186
189
 
@@ -20,6 +20,12 @@
20
20
  const { WorkspaceClient, SessionRevokedError } = require('../workspace-client');
21
21
  const { generateSessionTitle, SESSION_DEFAULT_RE } = require('./utils');
22
22
  const { defaultAgentWorkdir } = require('../paths');
23
+ const {
24
+ decisionLogTitle,
25
+ glossaryTitle,
26
+ WORKSPACE_GLOSSARY_TITLE,
27
+ pickEntryByTitle,
28
+ } = require('./decision-log');
23
29
  const {
24
30
  REASON,
25
31
  classifyJoinError,
@@ -85,6 +91,22 @@ class BaseAdapter {
85
91
  // workspace flag must reconnect/restart to pick up the change (matches
86
92
  // the Python adapter behavior in workspace_prompt.py).
87
93
  this._browserEnabledCache = null;
94
+ // ── Knowledge pinning (decision log + glossary) ──
95
+ // Entry-id caches make the steady state a single GET per pinned entry;
96
+ // reads happen on EVERY message, so they get a hard short deadline
97
+ // instead of the client's default 15s — a workspace hiccup must cost the
98
+ // turn a couple of seconds, not half a minute.
99
+ this._decisionEntryIds = {}; // channel → knowledge entry id of its decision log
100
+ this._glossaryEntryIds = {}; // channel → knowledge entry id of its glossary
101
+ this._DECISION_FETCH_TIMEOUT_MS = 2000;
102
+ this._warnedKnowledgeDisabled = false;
103
+ this._pinnedStatusWarned = new Set(); // channels already told pinning is inactive
104
+ // Subclasses whose sync prompt builders consume pinnedPromptOpts() set
105
+ // this true; the channel worker then refreshes _pinnedContext before
106
+ // every _handleMessage call. Adapters with their own pinning flow
107
+ // (Claude) fetch directly instead and leave it false.
108
+ this._usesPinnedContext = false;
109
+ this._pinnedContext = {}; // channel → { decisions, glossary }
88
110
  // Wall-clock timestamp of adapter init, used by the `status` control
89
111
  // action to report uptime back to the channel. Reset on reinstantiation
90
112
  // (e.g. after a `restart` IPC bounce) so uptime tracks "time since last
@@ -747,6 +769,7 @@ class BaseAdapter {
747
769
  async _channelWorker(channel, msg) {
748
770
  this._channelBusy.add(channel);
749
771
  try {
772
+ await this._prefetchPinnedContext(channel);
750
773
  await this._handleMessage(msg);
751
774
  } catch (e) {
752
775
  this._log(`Error in channel worker for ${channel}: ${e.message}`);
@@ -762,6 +785,8 @@ class BaseAdapter {
762
785
  try { await this.sendStatus(channel, 'processing queued message', { queue_id: nextMsg._queueId, queue_status: 'processed' }); } catch {}
763
786
  }
764
787
  try {
788
+ // Pinned entries may have changed while this message waited.
789
+ await this._prefetchPinnedContext(channel);
765
790
  await this._handleMessage(nextMsg);
766
791
  } catch (e) {
767
792
  this._log(`Error processing queued message in ${channel}: ${e.message}`);
@@ -771,6 +796,200 @@ class BaseAdapter {
771
796
  this._channelBusy.delete(channel);
772
797
  }
773
798
 
799
+ // ------------------------------------------------------------------
800
+ // Knowledge pinning (decision log + glossary)
801
+ // ------------------------------------------------------------------
802
+
803
+ /**
804
+ * Read one pinned knowledge entry with a short per-request deadline
805
+ * (worst case one list + one get, ~2x that budget). `titles` is tried in
806
+ * precedence order against a single listing; `cache` maps channel → entry
807
+ * id. Returns { available, state, entryId, content, error }:
808
+ * - available=false → knowledge module disabled, pinning inactive
809
+ * - state 'found' → the entry exists (entryId set; content null when a
810
+ * transient error hid it this turn)
811
+ * - state 'absent' → a successful listing confirmed no entry exists
812
+ * - state 'unknown' → fetch failed before existence could be confirmed;
813
+ * the prompt must NOT claim the entry is missing, or a
814
+ * recovered network mid-turn produces a duplicate
815
+ * - error=true → transient failure; callers must NOT treat this as
816
+ * "entry changed/empty"
817
+ * The matched entry id is cached per channel so the steady state is a
818
+ * single GET; a 404 or a soft-deleted entry (the backend keeps deleted
819
+ * rows readable by id with status "deleted") invalidates the cache and
820
+ * falls back to list+match, which only sees active entries.
821
+ */
822
+ async _fetchPinnedEntry(channel, titles, cache, label) {
823
+ if ((this.disabledModules || new Set()).has('knowledge')) {
824
+ if (!this._warnedKnowledgeDisabled) {
825
+ this._warnedKnowledgeDisabled = true;
826
+ this._log(
827
+ 'Knowledge module is disabled — decision-log pinning is INACTIVE. ' +
828
+ 'Confirmed decisions will not survive context compaction or session resets.'
829
+ );
830
+ }
831
+ if (!this._pinnedStatusWarned.has(channel)) {
832
+ this._pinnedStatusWarned.add(channel);
833
+ try {
834
+ await this.sendStatus(
835
+ channel,
836
+ 'Decision pinning inactive: the knowledge module is disabled for ' +
837
+ 'this agent, so confirmed decisions and the shared glossary ' +
838
+ 'cannot be pinned across session resets.'
839
+ );
840
+ } catch {}
841
+ }
842
+ return { available: false, state: 'unknown', entryId: null, title: null, content: null, error: false };
843
+ }
844
+
845
+ const timeout = this._DECISION_FETCH_TIMEOUT_MS;
846
+ // Only highest-precedence matches are ever cached (see below), so a
847
+ // cached id always belongs to titles[0].
848
+ const cachedId = cache[channel];
849
+ if (cachedId) {
850
+ try {
851
+ const entry = await this.client.getKnowledge(this.workspaceId, this.token, cachedId, { timeout });
852
+ if (entry && entry.status && entry.status !== 'active') {
853
+ // Soft-deleted: still served by id, but dead for updates.
854
+ delete cache[channel];
855
+ // Fall through to list+match below.
856
+ } else if (entry && typeof entry.title === 'string' && entry.title !== titles[0]) {
857
+ // Renamed (e.g. archived): the id no longer owns the canonical
858
+ // title, so the pin must re-resolve — a fallback or a replacement
859
+ // entry has to win over the stale cache.
860
+ delete cache[channel];
861
+ // Fall through to list+match below.
862
+ } else {
863
+ return { available: true, state: 'found', entryId: cachedId, title: titles[0], content: (entry && entry.content) || '', error: false };
864
+ }
865
+ } catch (e) {
866
+ if (/not found|HTTP 404/i.test(e.message || '')) {
867
+ delete cache[channel];
868
+ // Entry is gone — fall through to list+match below.
869
+ } else {
870
+ this._log(`${label} read failed (${e.message}) — reusing last known state`);
871
+ return { available: true, state: 'found', entryId: cachedId, title: titles[0], content: null, error: true };
872
+ }
873
+ }
874
+ }
875
+
876
+ try {
877
+ const data = await this.client.listKnowledge(this.workspaceId, this.token, { limit: 500, timeout });
878
+ const entries = (data && data.entries) || [];
879
+ let entry = null;
880
+ let matchedTitle = null;
881
+ for (const title of titles) {
882
+ const picked = pickEntryByTitle(entries, title);
883
+ if (picked.duplicates > 0) {
884
+ this._log(
885
+ `${label} WARNING for ${channel} — ${picked.duplicates + 1} knowledge entries share the title ` +
886
+ `"${title}"; using the earliest. The duplicates should be merged manually.`
887
+ );
888
+ }
889
+ if (picked.entry) { entry = picked.entry; matchedTitle = title; break; }
890
+ }
891
+ if (!entry) return { available: true, state: 'absent', entryId: null, title: null, content: null, error: false };
892
+ // Cache ONLY a highest-precedence match. Caching a fallback (e.g. the
893
+ // workspace glossary) under the channel key would let the cached-id
894
+ // fast path skip the precedence check forever, permanently masking a
895
+ // channel-specific entry created later. Fallback users pay one extra
896
+ // list per message instead.
897
+ if (matchedTitle === titles[0]) cache[channel] = entry.id;
898
+ const full = await this.client.getKnowledge(this.workspaceId, this.token, entry.id, { timeout });
899
+ if (full && full.status && full.status !== 'active') {
900
+ // Deleted between the listing and the read.
901
+ delete cache[channel];
902
+ return { available: true, state: 'absent', entryId: null, title: null, content: null, error: false };
903
+ }
904
+ return { available: true, state: 'found', entryId: entry.id, title: matchedTitle, content: (full && full.content) || '', error: false };
905
+ } catch (e) {
906
+ this._log(`${label} fetch failed (${e.message}) — reusing last known state`);
907
+ const knownId = cache[channel] || null;
908
+ return { available: true, state: knownId ? 'found' : 'unknown', entryId: knownId, title: knownId ? titles[0] : null, content: null, error: true };
909
+ }
910
+ }
911
+
912
+ /** Read the channel's decision log. See _fetchPinnedEntry for the contract. */
913
+ async _fetchDecisionLog(channel) {
914
+ return this._fetchPinnedEntry(channel, [decisionLogTitle(channel)], this._decisionEntryIds, 'Decision log');
915
+ }
916
+
917
+ /**
918
+ * Read the channel's glossary — the channel-specific entry when it exists,
919
+ * else the workspace-wide fallback. See _fetchPinnedEntry for the contract.
920
+ * The result carries scope 'channel' | 'workspace' (null when not found)
921
+ * so prompts can treat the shared fallback as read-only.
922
+ */
923
+ async _fetchGlossary(channel) {
924
+ const res = await this._fetchPinnedEntry(
925
+ channel,
926
+ [glossaryTitle(channel), WORKSPACE_GLOSSARY_TITLE],
927
+ this._glossaryEntryIds,
928
+ 'Glossary'
929
+ );
930
+ res.scope = res.title === WORKSPACE_GLOSSARY_TITLE ? 'workspace'
931
+ : res.title ? 'channel' : null;
932
+ return res;
933
+ }
934
+
935
+ /** Fetch everything pinnable for a channel: { decisions, glossary }. */
936
+ async _fetchPinnedContext(channel) {
937
+ const decisions = await this._fetchDecisionLog(channel);
938
+ const glossary = await this._fetchGlossary(channel);
939
+ return { decisions, glossary };
940
+ }
941
+
942
+ /**
943
+ * Refresh the pinned context ahead of _handleMessage for adapters that
944
+ * opted in (_usesPinnedContext). A transient read failure must not wipe
945
+ * the last successful pin — prompt-per-turn adapters would lose the
946
+ * authoritative definitions for that turn — so an errored entry keeps the
947
+ * previous good result; a thrown fetch keeps the previous context whole.
948
+ */
949
+ async _prefetchPinnedContext(channel) {
950
+ if (!this._usesPinnedContext) return;
951
+ try {
952
+ const fresh = await this._fetchPinnedContext(channel);
953
+ const prev = this._pinnedContext[channel];
954
+ if (prev) {
955
+ if (fresh.decisions && fresh.decisions.error && prev.decisions && !prev.decisions.error) {
956
+ fresh.decisions = prev.decisions;
957
+ }
958
+ if (fresh.glossary && fresh.glossary.error && prev.glossary && !prev.glossary.error) {
959
+ fresh.glossary = prev.glossary;
960
+ }
961
+ }
962
+ this._pinnedContext[channel] = fresh;
963
+ } catch (e) {
964
+ this._log(`Pinned-context fetch failed (${e && e.message ? e.message : e}) — reusing the last pinned context`);
965
+ }
966
+ }
967
+
968
+ /**
969
+ * Prompt-builder options ({ decisionLog, glossary }) derived from the last
970
+ * prefetch for the channel. Sync, so prompt builders can call it inline;
971
+ * returns {} when nothing was prefetched or nothing is usable.
972
+ */
973
+ pinnedPromptOpts(channel) {
974
+ const ctx = this._pinnedContext[channel];
975
+ if (!ctx) return {};
976
+ const opts = {};
977
+ const d = ctx.decisions;
978
+ if (d && d.available) {
979
+ opts.decisionLog = {
980
+ enabled: true,
981
+ state: d.state,
982
+ entryId: d.entryId,
983
+ content: d.error ? '' : (d.content || ''),
984
+ };
985
+ }
986
+ const g = ctx.glossary;
987
+ if (g && g.available && g.state === 'found' && g.content) {
988
+ opts.glossary = { enabled: true, entryId: g.entryId, content: g.content, scope: g.scope || 'channel' };
989
+ }
990
+ return opts;
991
+ }
992
+
774
993
  // ------------------------------------------------------------------
775
994
  // Auto-title helper
776
995
  // ------------------------------------------------------------------
@@ -19,7 +19,7 @@ const { execSync, spawn } = require('child_process');
19
19
  const BaseAdapter = require('./base');
20
20
  const { formatAttachmentsForPrompt, SESSION_DEFAULT_RE, generateSessionTitle } = require('./utils');
21
21
  const { buildClaudeSystemPrompt, buildClaudeSkillMd, workspaceSkillName } = require('./workspace-prompt');
22
- const { decisionLogTitle, decisionFingerprint, pickDecisionEntry, sampleRecap } = require('./decision-log');
22
+ const { pinnedFingerprint, sampleRecap } = require('./decision-log');
23
23
  const { defaultAgentWorkdir, whichBinary, whereBinary } = require('../paths');
24
24
 
25
25
  const IS_WINDOWS = process.platform === 'win32';
@@ -99,12 +99,9 @@ class ClaudeAdapter extends BaseAdapter {
99
99
  // a new message starts processing in the channel.
100
100
  this._stopNoticeSent = new Set();
101
101
  this._persistentProcs = {}; // channel → { proc, lineBuffer, pendingLines, idleTimer, messageResolve }
102
- this._decisionEntryIds = {}; // channel → knowledge entry id of its decision log
103
- // Decision-log reads happen on EVERY message, so they get a hard short
104
- // deadline instead of the client's default 15s — a workspace hiccup must
105
- // cost the turn a couple of seconds, not half a minute.
106
- this._DECISION_FETCH_TIMEOUT_MS = 2000;
107
- this._warnedKnowledgeDisabled = false;
102
+ // Knowledge pinning (decision log + glossary) lives in BaseAdapter; this
103
+ // adapter fetches directly in _handleMessage because the result also
104
+ // drives the persistent-process staleness check.
108
105
  this._IDLE_TIMEOUT_MS = 60 * 60 * 1000; // 1 hour
109
106
  this._WATCHDOG_INTERVAL_MS = 15_000; // 15s between checks
110
107
  this._WATCHDOG_MAX_TIMEOUTS = 20; // 20 * 15s = 5 min of silence → kill
@@ -315,85 +312,6 @@ class ClaudeAdapter extends BaseAdapter {
315
312
  );
316
313
  }
317
314
 
318
- /**
319
- * Read the channel's decision log (a knowledge entry) with a short
320
- * per-request deadline (worst case one list + one get, ~2x that budget).
321
- * Returns { available, state, entryId, content, error }:
322
- * - available=false → knowledge module disabled, pinning inactive
323
- * - state 'found' → the entry exists (entryId set; content null when a
324
- * transient error hid it this turn)
325
- * - state 'absent' → a successful listing confirmed no entry exists
326
- * - state 'unknown' → fetch failed before existence could be confirmed;
327
- * the prompt must NOT claim the log is missing, or a
328
- * recovered network mid-turn produces a duplicate
329
- * - error=true → transient failure; callers must NOT treat this as
330
- * "log changed/empty"
331
- * The matched entry id is cached per channel so the steady state is a
332
- * single GET; a 404 or a soft-deleted entry (the backend keeps deleted
333
- * rows readable by id with status "deleted") invalidates the cache and
334
- * falls back to list+match, which only sees active entries.
335
- */
336
- async _fetchDecisionLog(channel) {
337
- if (this.disabledModules.has('knowledge')) {
338
- if (!this._warnedKnowledgeDisabled) {
339
- this._warnedKnowledgeDisabled = true;
340
- this._log(
341
- 'Knowledge module is disabled — decision-log pinning is INACTIVE. ' +
342
- 'Confirmed decisions will not survive context compaction or session resets.'
343
- );
344
- }
345
- return { available: false, state: 'unknown', entryId: null, content: null, error: false };
346
- }
347
-
348
- const timeout = this._DECISION_FETCH_TIMEOUT_MS;
349
- const cachedId = this._decisionEntryIds[channel];
350
- if (cachedId) {
351
- try {
352
- const entry = await this.client.getKnowledge(this.workspaceId, this.token, cachedId, { timeout });
353
- if (entry && entry.status && entry.status !== 'active') {
354
- // Soft-deleted: still served by id, but dead for updates.
355
- delete this._decisionEntryIds[channel];
356
- // Fall through to list+match below.
357
- } else {
358
- return { available: true, state: 'found', entryId: cachedId, content: (entry && entry.content) || '', error: false };
359
- }
360
- } catch (e) {
361
- if (/not found|HTTP 404/i.test(e.message || '')) {
362
- delete this._decisionEntryIds[channel];
363
- // Entry is gone — fall through to list+match below.
364
- } else {
365
- this._log(`Decision log read failed (${e.message}) — reusing last known state`);
366
- return { available: true, state: 'found', entryId: cachedId, content: null, error: true };
367
- }
368
- }
369
- }
370
-
371
- try {
372
- const data = await this.client.listKnowledge(this.workspaceId, this.token, { limit: 500, timeout });
373
- const entries = (data && data.entries) || [];
374
- const { entry, duplicates } = pickDecisionEntry(entries, channel);
375
- if (duplicates > 0) {
376
- this._log(
377
- `Decision log WARNING for ${channel} — ${duplicates + 1} knowledge entries share the title ` +
378
- `"${decisionLogTitle(channel)}"; using the earliest. The duplicates should be merged manually.`
379
- );
380
- }
381
- if (!entry) return { available: true, state: 'absent', entryId: null, content: null, error: false };
382
- this._decisionEntryIds[channel] = entry.id;
383
- const full = await this.client.getKnowledge(this.workspaceId, this.token, entry.id, { timeout });
384
- if (full && full.status && full.status !== 'active') {
385
- // Deleted between the listing and the read.
386
- delete this._decisionEntryIds[channel];
387
- return { available: true, state: 'absent', entryId: null, content: null, error: false };
388
- }
389
- return { available: true, state: 'found', entryId: entry.id, content: (full && full.content) || '', error: false };
390
- } catch (e) {
391
- this._log(`Decision log fetch failed (${e.message}) — reusing last known state`);
392
- const knownId = this._decisionEntryIds[channel] || null;
393
- return { available: true, state: knownId ? 'found' : 'unknown', entryId: knownId, content: null, error: true };
394
- }
395
- }
396
-
397
315
  /**
398
316
  * Post "Execution stopped by user." at most once per channel for a given
399
317
  * stop. The control-action handler and the in-flight message handler both
@@ -530,7 +448,7 @@ class ClaudeAdapter extends BaseAdapter {
530
448
  return null;
531
449
  }
532
450
 
533
- _buildClaudeCmd(prompt, channelName, { skipResume = false, browserEnabled = false, decisionLog = null } = {}) {
451
+ _buildClaudeCmd(prompt, channelName, { skipResume = false, browserEnabled = false, decisionLog = null, glossary = null } = {}) {
534
452
  const claudeBin = this._findClaudeBinary();
535
453
  if (!claudeBin) {
536
454
  throw new Error('claude CLI not found. Install with: curl -fsSL https://claude.ai/install.sh | bash');
@@ -547,6 +465,7 @@ class ClaudeAdapter extends BaseAdapter {
547
465
  browserEnabled,
548
466
  toolMode: this.toolMode,
549
467
  decisionLog,
468
+ glossary,
550
469
  });
551
470
 
552
471
  const cmd = [claudeBin, '-p', prompt, '--output-format', 'stream-json', '--verbose'];
@@ -637,6 +556,12 @@ class ClaudeAdapter extends BaseAdapter {
637
556
  mcpTools.push(`${pfx}workspace_image_search`);
638
557
  mcpWriteTools.push(`${pfx}workspace_image_save`);
639
558
  }
559
+ if (!this.disabledModules.has('knowledge')) {
560
+ // The decision-log/glossary protocol instructs these by name — they
561
+ // must be callable or the pinned knowledge silently stops updating.
562
+ mcpTools.push(`${pfx}workspace_list_knowledge`, `${pfx}workspace_read_knowledge`);
563
+ mcpWriteTools.push(`${pfx}workspace_write_knowledge`);
564
+ }
640
565
  if (!this.disabledModules.has('browser')) {
641
566
  mcpTools.push(
642
567
  `${pfx}workspace_fetch_url`,
@@ -679,6 +604,7 @@ class ClaudeAdapter extends BaseAdapter {
679
604
  ];
680
605
  if (this.disabledModules.has('files')) mcpArgs.push('--disable-files');
681
606
  if (this.disabledModules.has('browser')) mcpArgs.push('--disable-browser');
607
+ if (this.disabledModules.has('knowledge')) mcpArgs.push('--disable-knowledge');
682
608
 
683
609
  // Resolve the MCP server entry point
684
610
  let mcpCommand = this._findNodeBin();
@@ -1205,17 +1131,26 @@ class ClaudeAdapter extends BaseAdapter {
1205
1131
 
1206
1132
  await this.sendStatus(msgChannel, 'thinking...');
1207
1133
 
1208
- // Read the channel's decision log up front — the fast-path staleness
1209
- // check and any (re)spawn below both need it.
1134
+ // Read the channel's pinned knowledge (decision log + glossary) up front
1135
+ // — the fast-path staleness check and any (re)spawn below both need it.
1210
1136
  const decisions = await this._fetchDecisionLog(msgChannel);
1211
- // null fingerprint = fetch failed, current state unknown; never treat
1137
+ const glossary = await this._fetchGlossary(msgChannel);
1138
+ // null fingerprint = a fetch failed, current state unknown; never treat
1212
1139
  // that as a change (it would churn respawns on every workspace hiccup).
1213
- // The fingerprint covers entry id AND content — both are pinned into the
1214
- // prompt, and a recreated entry can change id without changing content.
1215
- const decisionHash = decisions.error ? null : decisionFingerprint(decisions.entryId, decisions.content);
1140
+ // The fingerprint covers entry ids AND content — both are pinned into
1141
+ // the prompt, and a recreated entry can change id without changing content.
1142
+ const pinnedHash = (decisions.error || glossary.error)
1143
+ ? null
1144
+ : pinnedFingerprint([
1145
+ { entryId: decisions.entryId, content: decisions.content },
1146
+ { entryId: glossary.entryId, content: glossary.content },
1147
+ ]);
1216
1148
  const decisionLogOpt = decisions.available
1217
1149
  ? { enabled: true, state: decisions.state, entryId: decisions.entryId, content: decisions.error ? '' : (decisions.content || '') }
1218
1150
  : null;
1151
+ const glossaryOpt = (glossary.available && glossary.state === 'found' && glossary.content)
1152
+ ? { enabled: true, entryId: glossary.entryId, content: glossary.content, scope: glossary.scope || 'channel' }
1153
+ : null;
1219
1154
 
1220
1155
  // ── Persistent process fast-path ──
1221
1156
  // If we have a living persistent process for this channel, send via stdin
@@ -1226,17 +1161,17 @@ class ClaudeAdapter extends BaseAdapter {
1226
1161
  // system prompt AND the CLI permission flags (plan mode is read-only,
1227
1162
  // execute skips permissions) — so a process from the other mode can
1228
1163
  // never be reused. This check is deliberately independent of the
1229
- // decision fingerprint: a failed decision fetch must not keep a
1164
+ // pinned fingerprint: a failed knowledge fetch must not keep a
1230
1165
  // read-only plan process serving execute requests.
1231
1166
  const modeStale = existingPP.spawnMode !== this._mode;
1232
- const decisionsStale = decisionHash !== null && existingPP.decisionHash !== decisionHash;
1233
- if (modeStale || decisionsStale) {
1167
+ const pinnedStale = pinnedHash !== null && existingPP.pinnedHash !== pinnedHash;
1168
+ if (modeStale || pinnedStale) {
1234
1169
  // Kill it and fall through to a fresh spawn: --resume keeps the
1235
1170
  // conversation (it lives in the CLI transcript), while the new spawn
1236
- // carries the current mode and re-pins the current decisions.
1171
+ // carries the current mode and re-pins the current knowledge.
1237
1172
  this._log(modeStale
1238
1173
  ? `Mode changed to ${this._mode} for ${msgChannel} — respawning with resume`
1239
- : `Decision log changed for ${msgChannel} — respawning with resume to re-pin decisions`);
1174
+ : `Pinned knowledge changed for ${msgChannel} — respawning with resume to re-pin it`);
1240
1175
  await this._killPersistentProc(msgChannel);
1241
1176
  } else {
1242
1177
  this._log(`Reusing persistent process for ${msgChannel}`);
@@ -1302,6 +1237,7 @@ class ClaudeAdapter extends BaseAdapter {
1302
1237
  skipResume: attempt > 0,
1303
1238
  browserEnabled,
1304
1239
  decisionLog: decisionLogOpt,
1240
+ glossary: glossaryOpt,
1305
1241
  });
1306
1242
  cmd = built.cmd;
1307
1243
  mcpConfigFile = built.mcpConfigFile;
@@ -1313,11 +1249,14 @@ class ClaudeAdapter extends BaseAdapter {
1313
1249
  try {
1314
1250
  const pp = this._spawnPersistentProc(msgChannel, cmd, cleanEnv);
1315
1251
  // Remember the spawn-time configuration so the fast-path can detect
1316
- // staleness on later messages — the decision-log state and the mode
1252
+ // staleness on later messages — the pinned knowledge and the mode
1317
1253
  // (which fixes both the system prompt and the permission flags).
1318
- pp.decisionHash = decisionLogOpt
1319
- ? decisionFingerprint(decisionLogOpt.entryId, decisionLogOpt.content)
1320
- : decisionFingerprint(null, null);
1254
+ // When the fetch errored (pinnedHash null) hash what was actually
1255
+ // pinned, so the next successful fetch triggers a re-pin.
1256
+ pp.pinnedHash = pinnedHash !== null ? pinnedHash : pinnedFingerprint([
1257
+ { entryId: decisionLogOpt && decisionLogOpt.entryId, content: decisionLogOpt && decisionLogOpt.content },
1258
+ { entryId: glossaryOpt && glossaryOpt.entryId, content: glossaryOpt && glossaryOpt.content },
1259
+ ]);
1321
1260
  pp.spawnMode = this._mode;
1322
1261
  this._log(`Spawned persistent process for ${msgChannel} (attempt ${attempt + 1})`);
1323
1262
 
@@ -34,6 +34,8 @@ class CodexAdapter extends BaseAdapter {
34
34
  constructor(opts) {
35
35
  super(opts);
36
36
  this.disabledModules = opts.disabledModules || new Set();
37
+ // Pin the channel's decision log + glossary into the system prompt.
38
+ this._usesPinnedContext = true;
37
39
 
38
40
  const env = this.agentEnv || process.env;
39
41
  this._directApiKey = env.OPENAI_API_KEY || '';
@@ -174,6 +176,7 @@ class CodexAdapter extends BaseAdapter {
174
176
  token: this.token,
175
177
  mode: this._mode,
176
178
  disabledModules: this.disabledModules,
179
+ ...this.pinnedPromptOpts(channelName),
177
180
  });
178
181
  const skillsSection = this._buildInstalledSkillsSection();
179
182
  return skillsSection ? `${base}\n\n${skillsSection}` : base;
@@ -82,6 +82,8 @@ class CopilotAdapter extends BaseAdapter {
82
82
  constructor(opts) {
83
83
  super(opts);
84
84
  this.disabledModules = opts.disabledModules || new Set();
85
+ // Pin the channel's decision log + glossary into the system prompt.
86
+ this._usesPinnedContext = true;
85
87
 
86
88
  const env = this.agentEnv || process.env;
87
89
  // Official model env var (verified via `copilot help environment`).
@@ -325,6 +327,7 @@ class CopilotAdapter extends BaseAdapter {
325
327
  token: this.token,
326
328
  mode: this._mode,
327
329
  disabledModules: this.disabledModules,
330
+ ...this.pinnedPromptOpts(channelName),
328
331
  });
329
332
  }
330
333
 
@@ -1,11 +1,17 @@
1
1
  /**
2
- * Pure helpers for the Claude adapter's decision log ("constraint pinning").
2
+ * Pure helpers for knowledge pinning ("constraint pinning").
3
3
  *
4
- * The decision log is a per-channel knowledge entry that records every
5
- * decision the user has confirmed. The adapter re-reads it on every message
6
- * and injects it into the CLI system prompt — the only position that survives
7
- * both the CLI's auto-compaction and a session reset. Everything here is
8
- * side-effect free so it can be unit tested without an adapter instance.
4
+ * Two kinds of knowledge entries get pinned into agent prompts:
5
+ * - The decision log: a per-channel entry recording every decision the user
6
+ * has confirmed.
7
+ * - The glossary: a per-channel (or workspace-wide) entry defining shared
8
+ * terms and fields, so agents collaborating on the same spec cannot drift
9
+ * into different readings of the same name.
10
+ *
11
+ * Adapters re-read the pinned entries on every message and inject them into
12
+ * the prompt — the only position that survives both auto-compaction and a
13
+ * session reset. Everything here is side-effect free so it can be unit
14
+ * tested without an adapter instance.
9
15
  */
10
16
 
11
17
  'use strict';
@@ -27,6 +33,20 @@ function decisionLogTitle(channelName) {
27
33
  return `Decisions for channel ${channelName}`;
28
34
  }
29
35
 
36
+ /**
37
+ * Canonical knowledge-entry title for a channel's glossary. Same exact-title
38
+ * matching rules as the decision log.
39
+ */
40
+ function glossaryTitle(channelName) {
41
+ return `Glossary for channel ${channelName}`;
42
+ }
43
+
44
+ /**
45
+ * Workspace-wide glossary fallback: used when a channel has no glossary of
46
+ * its own, so one shared entry can serve every channel.
47
+ */
48
+ const WORKSPACE_GLOSSARY_TITLE = 'Workspace glossary';
49
+
30
50
  /**
31
51
  * Stable hash of the decision log content. null/undefined and '' hash
32
52
  * identically so "no entry yet" never reads as a change.
@@ -49,13 +69,26 @@ function decisionFingerprint(entryId, content) {
49
69
  }
50
70
 
51
71
  /**
52
- * Pick the channel's decision entry from a knowledge listing: exact-title
53
- * matches only, earliest created_at wins (concurrent creates produce
54
- * duplicates — the earliest is the one other turns have been updating).
72
+ * Fingerprint of EVERYTHING pinned into a spawned process's prompt — an
73
+ * ordered list of { entryId, content } pairs (decision log first, then the
74
+ * glossary). Any id or content change in any pinned entry changes the
75
+ * fingerprint, which is what triggers a respawn-with-resume to re-pin.
76
+ */
77
+ function pinnedFingerprint(parts) {
78
+ const h = crypto.createHash('sha256');
79
+ for (const p of parts || []) {
80
+ h.update(decisionFingerprint(p && p.entryId, p && p.content), 'utf-8');
81
+ }
82
+ return h.digest('hex');
83
+ }
84
+
85
+ /**
86
+ * Pick the entry with an exact title match from a knowledge listing;
87
+ * earliest created_at wins (concurrent creates produce duplicates — the
88
+ * earliest is the one other turns have been updating).
55
89
  * Returns { entry, duplicates } where duplicates counts the extra matches.
56
90
  */
57
- function pickDecisionEntry(entries, channelName) {
58
- const title = decisionLogTitle(channelName);
91
+ function pickEntryByTitle(entries, title) {
59
92
  const matches = (entries || []).filter((e) => e && e.title === title);
60
93
  if (matches.length === 0) return { entry: null, duplicates: 0 };
61
94
  const sorted = matches.slice().sort((a, b) => {
@@ -68,6 +101,11 @@ function pickDecisionEntry(entries, channelName) {
68
101
  return { entry: sorted[0], duplicates: matches.length - 1 };
69
102
  }
70
103
 
104
+ /** Back-compat wrapper: pick a channel's decision-log entry by its title. */
105
+ function pickDecisionEntry(entries, channelName) {
106
+ return pickEntryByTitle(entries, decisionLogTitle(channelName));
107
+ }
108
+
71
109
  /**
72
110
  * Fit the decision log into a character budget without ever cutting a line
73
111
  * in half. Over budget, whole lines are kept from the head (earliest
@@ -170,10 +208,14 @@ function sampleRecap(headMsgs, tailMsgs, currentMessage, { headKeep = 5, tailKee
170
208
  module.exports = {
171
209
  PINNED_DECISIONS_MAX_CHARS,
172
210
  RECAP_LINE_MAX_CHARS,
211
+ WORKSPACE_GLOSSARY_TITLE,
173
212
  decisionLogTitle,
213
+ glossaryTitle,
174
214
  hashDecisions,
175
215
  decisionFingerprint,
216
+ pinnedFingerprint,
176
217
  pickDecisionEntry,
218
+ pickEntryByTitle,
177
219
  renderPinnedDecisions,
178
220
  isRecapEligible,
179
221
  formatRecapLine,