@yemi33/minions 0.1.1989 → 0.1.1991

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/dashboard.js CHANGED
@@ -34,7 +34,7 @@ const features = require('./engine/features');
34
34
  const ccWorkerPool = require('./engine/cc-worker-pool');
35
35
  const os = require('os');
36
36
 
37
- const { safeRead, safeReadDir, safeWrite, safeJson, safeJsonObj, safeJsonArr, safeJsonNoRestore, safeUnlink, mutateJsonFileLocked, mutateTextFileLocked, mutateControl, mutateCooldowns, mutateWorkItems, getProjects: _getProjects, DONE_STATUSES, WI_STATUS, WORK_TYPE, WORKTREE_REQUIRING_TYPES, reopenWorkItem } = shared;
37
+ const { safeRead, safeReadOrNull, safeReadDir, safeWrite, safeJson, safeJsonObj, safeJsonArr, safeJsonNoRestore, safeUnlink, mutateJsonFileLocked, mutateTextFileLocked, mutateControl, mutateCooldowns, mutateWorkItems, getProjects: _getProjects, DONE_STATUSES, WI_STATUS, WORK_TYPE, WORKTREE_REQUIRING_TYPES, reopenWorkItem } = shared;
38
38
  const { getAgents, getAgentDetail, getPrdInfo, getWorkItems, getDispatchQueue,
39
39
  getSkills, getInbox, getNotesWithMeta, getPullRequests,
40
40
  getEngineLog, getMetrics, getKnowledgeBaseEntries, getProjectGitStatus, timeSince,
@@ -2640,11 +2640,31 @@ async function _preflightModelCheck({ runtime: cliOverride, model: modelOverride
2640
2640
  * document body. Always re-sending extraContext is correctness-safe; the
2641
2641
  * pool's warm-process saving is preserved regardless.
2642
2642
  */
2643
+ /**
2644
+ * Build the persistent doc-chat pool tabKey from a session key (filePath or
2645
+ * title). Single source of truth shared by _invokeDocChatViaPool (the
2646
+ * streaming path) and handleDocChatWarm (the pre-warm endpoint). Both call
2647
+ * sites MUST resolve to the same key, otherwise warm spawns an orphan worker
2648
+ * the streaming call ignores — silently breaking the skip-18-21s Copilot
2649
+ * cold-spawn optimization documented at modal-qa.js:506-516.
2650
+ *
2651
+ * The `default` fallback mirrors the legacy inline behavior for empty input
2652
+ * so older callers (and the dashboard test fixtures that omit sessionKey)
2653
+ * still land on a stable, predictable key.
2654
+ *
2655
+ * The freshSession one-shot branch in _invokeDocChatViaPool intentionally
2656
+ * bypasses this helper — those callers want a unique short-lived key under
2657
+ * `doc-chat:fresh:<uid>`, not the persistent namespace.
2658
+ */
2659
+ function _buildDocChatTabKey(sessionKey) {
2660
+ return 'doc-chat:' + (sessionKey || 'default');
2661
+ }
2662
+
2643
2663
  function _invokeDocChatViaPool({ prompt, model, effort, engineConfig, systemPrompt, sessionKey, freshSession, timeoutMs, onChunk, onToolUse, onToolUpdate }) {
2644
2664
  const oneShot = !!freshSession;
2645
2665
  const tabKey = oneShot
2646
2666
  ? 'doc-chat:fresh:' + shared.uid()
2647
- : 'doc-chat:' + (sessionKey || 'default');
2667
+ : _buildDocChatTabKey(sessionKey);
2648
2668
  let cancelled = false;
2649
2669
  let settled = false;
2650
2670
  let accumulated = '';
@@ -5905,7 +5925,10 @@ What would you like to discuss or change? When you're happy, say "approve" and I
5905
5925
  if (canEdit) {
5906
5926
  try { shared.sanitizePath(body.filePath, MINIONS_DIR); } catch { return jsonReply(res, 400, { error: 'path must be under minions directory' }); }
5907
5927
  fullPath = path.resolve(MINIONS_DIR, body.filePath);
5908
- const diskContent = safeRead(fullPath);
5928
+ // safeReadOrNull returns null when the file is missing/inaccessible.
5929
+ // If absent, keep body.document (the client-sent panel content) — do
5930
+ // NOT overwrite currentContent with '' (W-mpede4j7000ab7e4).
5931
+ const diskContent = safeReadOrNull(fullPath);
5909
5932
  if (diskContent !== null) {
5910
5933
  // If client sent a contentHash and it matches disk, skip replacement — client copy is fresh
5911
5934
  if (body.contentHash && contentFingerprint(diskContent) === body.contentHash) {
@@ -5992,7 +6015,10 @@ What would you like to discuss or change? When you're happy, say "approve" and I
5992
6015
  try { shared.sanitizePath(body.filePath, MINIONS_DIR); }
5993
6016
  catch { docChatInFlight.delete(docKey); res.statusCode = 400; res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ error: 'path must be under minions directory' })); return; }
5994
6017
  fullPath = path.resolve(MINIONS_DIR, body.filePath);
5995
- const diskContent = safeRead(fullPath);
6018
+ // safeReadOrNull returns null when the file is missing/inaccessible.
6019
+ // If absent, keep body.document (the client-sent panel content) — do
6020
+ // NOT overwrite currentContent with '' (W-mpede4j7000ab7e4).
6021
+ const diskContent = safeReadOrNull(fullPath);
5996
6022
  if (diskContent !== null) {
5997
6023
  if (!(body.contentHash && contentFingerprint(diskContent) === body.contentHash)) {
5998
6024
  currentContent = diskContent;
@@ -6595,7 +6621,10 @@ What would you like to discuss or change? When you're happy, say "approve" and I
6595
6621
  const body = await readBody(req);
6596
6622
  const sessionKey = body && (body.filePath || body.title);
6597
6623
  if (!sessionKey) return jsonReply(res, 400, { error: 'filePath or title required' });
6598
- const result = await _warmCcPool(sessionKey, _docChatPromptHash);
6624
+ // Must match the persistent tabKey _invokeDocChatViaPool hands to
6625
+ // ccWorkerPool.getSession at first-message time, or this warm spawns
6626
+ // an orphan worker (regression W-mpede4bf000957b5).
6627
+ const result = await _warmCcPool(_buildDocChatTabKey(sessionKey), _docChatPromptHash);
6599
6628
  return jsonReply(res, 200, result);
6600
6629
  } catch (e) { return jsonReply(res, e.statusCode || 500, { error: e.message }); }
6601
6630
  }
@@ -9261,6 +9290,7 @@ module.exports = {
9261
9290
  parsePinnedEntries,
9262
9291
  _formatDocChatContext,
9263
9292
  _buildDocChatPass,
9293
+ _buildDocChatTabKey,
9264
9294
  _docSessionsForTesting: docSessions,
9265
9295
  _docChatPromptHashForTesting: _docChatPromptHash,
9266
9296
  _isCompletedMeetingJson,
@@ -66,26 +66,65 @@ function isAdoProject(project) {
66
66
  return !!(project && project.repoHost === 'ado');
67
67
  }
68
68
 
69
- // Scope the header to the ADO host so a misconfigured remote pointing at
69
+ // Scope the header to the ADO host(s) so a misconfigured remote pointing at
70
70
  // another host (e.g. github.com on a misclassified project) doesn't leak the
71
71
  // bearer token. Falls back to the unscoped `http.extraHeader` key when we
72
- // can't compute a host (still safe — git only sends extraHeader on HTTP/S
72
+ // can't compute any host (still safe — git only sends extraHeader on HTTP/S
73
73
  // transfers, and the engine only invokes ado-git-auth for ADO projects).
74
- function _resolveScopeUrl(project) {
74
+ //
75
+ // W-mped1zap00069ea5 — ADO has TWO canonical URL forms for the same org:
76
+ // - https://dev.azure.com/<org>/... (modern, default for new clones)
77
+ // - https://<org>.visualstudio.com/... (legacy alias, still active)
78
+ // Git scopes `http.<url>.extraHeader` by URL PREFIX match: a header scoped to
79
+ // `office.visualstudio.com` is silently dropped when the remote is
80
+ // `dev.azure.com/office` (and vice-versa). The bearer token is identical for
81
+ // either form, so we emit the header under BOTH scopes to cover whichever
82
+ // alias the project's git remote happens to use.
83
+ function _resolveScopeUrls(project) {
75
84
  try {
76
- if (!project || !project.adoOrg) return null;
85
+ if (!isAdoProject(project) || !project.adoOrg) return [];
86
+ const scopes = new Set();
87
+
88
+ // Always include the modern dev.azure.com host. Remotes cloned via the
89
+ // current ADO UI use this form regardless of what `prUrlBase` says.
90
+ scopes.add('https://dev.azure.com/');
91
+
92
+ // Derive the visualstudio.com form from adoOrg. Works whether adoOrg is
93
+ // the bare org slug ("office") or already a full host ("office.visualstudio.com").
94
+ const orgName = project.adoOrg.includes('.')
95
+ ? project.adoOrg.split('.')[0]
96
+ : project.adoOrg;
97
+ if (orgName) {
98
+ scopes.add(`https://${orgName}.visualstudio.com/`);
99
+ }
100
+
101
+ // Also honor whatever host `getAdoOrgBase` resolves — covers custom
102
+ // prUrlBase values (e.g. self-hosted Azure DevOps Server) that don't
103
+ // match either canonical alias.
77
104
  const base = shared.getAdoOrgBase(project);
78
- if (typeof base !== 'string' || !base.startsWith('http')) return null;
79
- const m = base.match(/^(https?:\/\/[^/]+)/);
80
- return m ? `${m[1]}/` : null;
105
+ if (typeof base === 'string' && base.startsWith('http')) {
106
+ const m = base.match(/^(https?:\/\/[^/]+)/);
107
+ if (m) scopes.add(`${m[1]}/`);
108
+ }
109
+
110
+ // Stable alphabetical order so tests can assert deterministically.
111
+ return Array.from(scopes).sort();
81
112
  } catch (_e) {
82
- return null;
113
+ return [];
83
114
  }
84
115
  }
85
116
 
86
- function _buildHeaderArgs(token, scopeUrl) {
87
- const key = scopeUrl ? `http.${scopeUrl}.extraHeader` : 'http.extraHeader';
88
- return ['-c', `${key}=Authorization: Bearer ${token}`];
117
+ function _buildHeaderArgs(token, scopeUrls) {
118
+ // Empty/null scopes degenerate unscoped fallback (matches pre-W-mped1zap
119
+ // behavior). Still safe because we only inject this for ADO projects.
120
+ if (!Array.isArray(scopeUrls) || scopeUrls.length === 0) {
121
+ return ['-c', `http.extraHeader=Authorization: Bearer ${token}`];
122
+ }
123
+ const args = [];
124
+ for (const scopeUrl of scopeUrls) {
125
+ args.push('-c', `http.${scopeUrl}.extraHeader=Authorization: Bearer ${token}`);
126
+ }
127
+ return args;
89
128
  }
90
129
 
91
130
  function _acquireToken(opts = {}) {
@@ -98,7 +137,7 @@ function getAdoGitExtraArgs(project, opts = {}) {
98
137
  if (!isAdoProject(project)) return [];
99
138
  const now = Date.now();
100
139
  if (_cached && _cached.expiresAt > now) {
101
- return _buildHeaderArgs(_cached.token, _resolveScopeUrl(project));
140
+ return _buildHeaderArgs(_cached.token, _resolveScopeUrls(project));
102
141
  }
103
142
  if (now < _backoffUntil) return [];
104
143
  try {
@@ -109,7 +148,7 @@ function getAdoGitExtraArgs(project, opts = {}) {
109
148
  return [];
110
149
  }
111
150
  _cached = { token, expiresAt: now + TOKEN_TTL_MS };
112
- return _buildHeaderArgs(token, _resolveScopeUrl(project));
151
+ return _buildHeaderArgs(token, _resolveScopeUrls(project));
113
152
  } catch (e) {
114
153
  _backoffUntil = now + ACQUIRE_BACKOFF_MS;
115
154
  const firstLine = String(e && e.message || e).split('\n')[0];
package/engine/shared.js CHANGED
@@ -323,6 +323,15 @@ function safeRead(p) {
323
323
  try { return fs.readFileSync(p, 'utf8'); } catch { return ''; }
324
324
  }
325
325
 
326
+ // Like safeRead, but returns null when the file is missing/inaccessible so
327
+ // callers can distinguish "file absent" from "file present but empty".
328
+ // Used by doc-chat handlers (W-mpede4j7000ab7e4) to avoid silently overwriting
329
+ // the client-sent document body with '' when the file was deleted/moved
330
+ // between panel open and send.
331
+ function safeReadOrNull(p) {
332
+ try { return fs.readFileSync(p, 'utf8'); } catch { return null; }
333
+ }
334
+
326
335
  function safeReadDir(dir) {
327
336
  try { return fs.readdirSync(dir); } catch { return []; }
328
337
  }
@@ -4680,6 +4689,7 @@ module.exports = {
4680
4689
  dateStamp,
4681
4690
  log,
4682
4691
  safeRead,
4692
+ safeReadOrNull,
4683
4693
  safeReadDir,
4684
4694
  safeJson, safeJsonObj, safeJsonArr, safeJsonNoRestore,
4685
4695
  safeWrite,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1989",
3
+ "version": "0.1.1991",
4
4
  "description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
5
5
  "bin": {
6
6
  "minions": "bin/minions.js"