@indigoai-us/hq-cli 5.146.0 → 5.148.0
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/CHANGELOG.md +14 -0
- package/dist/commands/mcp-registration.d.ts +29 -16
- package/dist/commands/mcp-registration.js +254 -17
- package/dist/commands/mesh.js +19 -2
- package/dist/lib/mesh/api.d.ts +1 -1
- package/dist/lib/mesh/api.js +6 -2
- package/dist/lib/mesh/live/daemon/run.js +56 -2
- package/dist/lib/mesh/live/flush.d.ts +8 -0
- package/dist/lib/mesh/live/flush.js +71 -9
- package/dist/lib/mesh/live/session-seq.d.ts +4 -0
- package/dist/lib/mesh/live/session-seq.js +33 -0
- package/dist/lib/mesh/project-board.js +11 -3
- package/dist/lib/work-context/prompt-rebind.d.ts +6 -2
- package/dist/lib/work-context/prompt-rebind.js +14 -5
- package/dist/sentry.js +15 -7
- package/dist/utils/sentry-fingerprint.d.ts +11 -0
- package/dist/utils/sentry-fingerprint.js +56 -11
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.148.0] — 2026-09-23
|
|
6
|
+
|
|
7
|
+
## [5.147.0] — 2026-09-23
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Reinstalling an MCP server now updates its definition when the existing
|
|
12
|
+
Claude or Codex entry belongs to the same HQ pack. Unmarked and other-pack
|
|
13
|
+
entries still refuse replacement.
|
|
14
|
+
- `hq mesh context prd-sync` now moves the Board. Its task_status events carry the session's company and project, and take sequence numbers from the session's shared counter (the same file the hooks use), so the server no longer rejects them as duplicates. Rejections print their codes.
|
|
15
|
+
- The mesh daemon no longer treats a missing company slug, a membership timeout, or a lookup error as proof that the device owner is not a member. A device-default session that was cleared to `needs_company` is checked once against the configured default company and, when that membership is active, the company is put back and held events flush. The same cleared session is not counted as a new denial on every later flush.
|
|
16
|
+
- `hq mesh project ensure` no longer fails when the server refuses to close the extra genesis thread; it leaves that thread open and prints one line. Registration and the calling session's binding complete.
|
|
17
|
+
- The mesh daemon re-checks a `needs_project` or unresolved session when it flushes that session's events. If the project Board is now an exact or clear catalog match, the batch is posted with that project and the session file is bound. A tie or near miss stays `needs_project`. The check runs at most once a minute per session.
|
|
18
|
+
|
|
5
19
|
## [5.146.0] — 2026-09-23
|
|
6
20
|
|
|
7
21
|
### Fixed
|
|
@@ -74,6 +74,15 @@ export declare class ConfigParseError extends McpRegistrationError {
|
|
|
74
74
|
export declare class ConfigPermissionError extends McpRegistrationError {
|
|
75
75
|
readonly code: "ConfigPermissionError";
|
|
76
76
|
}
|
|
77
|
+
export type CodexMcpCollisionOwner = 'hq' | 'foreign' | 'unknown';
|
|
78
|
+
export type CodexMcpCollisionField = 'command' | 'args' | 'env' | 'url' | 'other';
|
|
79
|
+
/** Safe, value-free context for a Codex name collision reported to Sentry. */
|
|
80
|
+
export interface CodexMcpCollisionDiagnostics {
|
|
81
|
+
client: 'codex';
|
|
82
|
+
serverName: string;
|
|
83
|
+
existingEntryOwner: CodexMcpCollisionOwner;
|
|
84
|
+
diffFields: CodexMcpCollisionField[];
|
|
85
|
+
}
|
|
77
86
|
/**
|
|
78
87
|
* A server name is already present in the target config with a DIFFERENT
|
|
79
88
|
* definition than the one being merged (def-equal = idempotent no-op;
|
|
@@ -82,6 +91,8 @@ export declare class ConfigPermissionError extends McpRegistrationError {
|
|
|
82
91
|
*/
|
|
83
92
|
export declare class McpNameCollisionError extends McpRegistrationError {
|
|
84
93
|
readonly code: "McpNameCollisionError";
|
|
94
|
+
readonly codexDiagnostics?: CodexMcpCollisionDiagnostics;
|
|
95
|
+
constructor(message: string, codexDiagnostics?: CodexMcpCollisionDiagnostics);
|
|
85
96
|
}
|
|
86
97
|
/**
|
|
87
98
|
* The Codex runtime is absent (`~/.codex` missing). This is a FIRST-CLASS SKIP,
|
|
@@ -494,7 +505,8 @@ export declare function serverDefsEqual(a: unknown, b: unknown): boolean;
|
|
|
494
505
|
* it — but byte-equality of the rest means {@link writeConfigAtomic} writes
|
|
495
506
|
* nothing unless the stamp itself is the only delta, which we treat as a no-op
|
|
496
507
|
* by returning the original doc.
|
|
497
|
-
* - name PRESENT, def-DIFFERS ->
|
|
508
|
+
* - name PRESENT, def-DIFFERS, same non-empty `_hqPack` -> replace the entry.
|
|
509
|
+
* - name PRESENT, def-DIFFERS, unmarked or different owner -> collision ABORT.
|
|
498
510
|
*
|
|
499
511
|
* Never touches any OTHER server entry or any other top-level key.
|
|
500
512
|
*/
|
|
@@ -646,8 +658,8 @@ type TomlTable = Record<string, unknown>;
|
|
|
646
658
|
* The Codex doc threaded through the safe-write core. We carry BOTH the parsed
|
|
647
659
|
* value (`value`, for the merge fn + verify) AND the ORIGINAL file text
|
|
648
660
|
* (`originalText`, `null` for a fresh/empty file). {@link codexConfigFormat.serialize}
|
|
649
|
-
* uses `originalText` to APPEND
|
|
650
|
-
*
|
|
661
|
+
* uses `originalText` to APPEND a new table or replace sections of a same-pack
|
|
662
|
+
* owned table without re-serializing unrelated content. This is the seam that
|
|
651
663
|
* makes "preserves comments" honest within a value-only TOML library.
|
|
652
664
|
*/
|
|
653
665
|
export interface CodexTomlDoc {
|
|
@@ -661,7 +673,7 @@ export declare const CODEX_MCP_SERVERS_KEY = "mcp_servers";
|
|
|
661
673
|
/**
|
|
662
674
|
* Serialize EXACTLY ONE server table (`[mcp_servers.<name>]` + its sub-tables)
|
|
663
675
|
* by wrapping the def under `mcp_servers.<name>` and round-tripping it through
|
|
664
|
-
* smol-toml. Used
|
|
676
|
+
* smol-toml. Used for appending new servers, replacing an owned table, and the
|
|
665
677
|
* collision/equality comparison. The output is deterministic for a given def so a
|
|
666
678
|
* re-install produces byte-identical text (idempotency depends on it).
|
|
667
679
|
*/
|
|
@@ -670,15 +682,15 @@ export declare function serializeOneCodexServer(name: string, def: TomlTable): s
|
|
|
670
682
|
* The Codex `ConfigFormat`. `parse` round-trips the file through smol-toml
|
|
671
683
|
* (throwing `TomlError` on malformed NON-empty input → the substrate maps it to
|
|
672
684
|
* {@link ConfigParseError}, FAIL-CLOSED). `serialize` is comment-preserving: when
|
|
673
|
-
* the doc carries `originalText` (the file pre-existed) it APPENDS
|
|
674
|
-
*
|
|
675
|
-
* whole value
|
|
676
|
-
*
|
|
677
|
-
*
|
|
678
|
-
* Because `serialize` needs
|
|
679
|
-
*
|
|
680
|
-
*
|
|
681
|
-
*
|
|
685
|
+
* the doc carries `originalText` (the file pre-existed) it APPENDS a new server
|
|
686
|
+
* table or replaces only a same-pack owned table, rather than re-serializing the
|
|
687
|
+
* whole value. Only a fresh file (`null`) is serialized from scratch.
|
|
688
|
+
* `assertEntry` checks `mcp_servers.<name>` is present.
|
|
689
|
+
*
|
|
690
|
+
* Because `serialize` needs to know WHICH server to append or replace, the merge
|
|
691
|
+
* fn ({@link mergeCodexServer}) records the operation on the doc. For an
|
|
692
|
+
* idempotent no-op (server already present, def-equal) the merge returns the doc
|
|
693
|
+
* UNCHANGED, so
|
|
682
694
|
* `serialize` reproduces the original text exactly (no write happens).
|
|
683
695
|
*/
|
|
684
696
|
export declare const codexConfigFormat: ConfigFormat<CodexTomlDoc>;
|
|
@@ -716,9 +728,10 @@ export declare function buildCodexToolsTable(tools: McpManifest['tools']): TomlT
|
|
|
716
728
|
* - name PRESENT, def-equal (provenance-ignored) → NO-OP (idempotent re-install /
|
|
717
729
|
* PARTIAL self-heal): return the doc UNCHANGED with no pending append, so the
|
|
718
730
|
* original text round-trips byte-for-byte.
|
|
719
|
-
* - name PRESENT, def-DIFFERS →
|
|
720
|
-
*
|
|
721
|
-
*
|
|
731
|
+
* - name PRESENT, def-DIFFERS, same `_hqPack` owner → update that owned table
|
|
732
|
+
* while preserving unrelated TOML bytes and comments.
|
|
733
|
+
* - name PRESENT, def-DIFFERS, any other owner → {@link McpNameCollisionError}
|
|
734
|
+
* ABORT with value-free diagnostics.
|
|
722
735
|
*
|
|
723
736
|
* Never touches any OTHER server table or any other top-level table.
|
|
724
737
|
*/
|
|
@@ -99,6 +99,11 @@ export class ConfigPermissionError extends McpRegistrationError {
|
|
|
99
99
|
*/
|
|
100
100
|
export class McpNameCollisionError extends McpRegistrationError {
|
|
101
101
|
code = 'McpNameCollisionError';
|
|
102
|
+
codexDiagnostics;
|
|
103
|
+
constructor(message, codexDiagnostics) {
|
|
104
|
+
super(message);
|
|
105
|
+
this.codexDiagnostics = codexDiagnostics;
|
|
106
|
+
}
|
|
102
107
|
}
|
|
103
108
|
/**
|
|
104
109
|
* The Codex runtime is absent (`~/.codex` missing). This is a FIRST-CLASS SKIP,
|
|
@@ -1009,7 +1014,8 @@ function canonicalJson(v) {
|
|
|
1009
1014
|
* it — but byte-equality of the rest means {@link writeConfigAtomic} writes
|
|
1010
1015
|
* nothing unless the stamp itself is the only delta, which we treat as a no-op
|
|
1011
1016
|
* by returning the original doc.
|
|
1012
|
-
* - name PRESENT, def-DIFFERS ->
|
|
1017
|
+
* - name PRESENT, def-DIFFERS, same non-empty `_hqPack` -> replace the entry.
|
|
1018
|
+
* - name PRESENT, def-DIFFERS, unmarked or different owner -> collision ABORT.
|
|
1013
1019
|
*
|
|
1014
1020
|
* Never touches any OTHER server entry or any other top-level key.
|
|
1015
1021
|
*/
|
|
@@ -1023,6 +1029,12 @@ export function mergeClaudeServer(name, def) {
|
|
|
1023
1029
|
// (do not re-stamp) so a re-install is a true byte-for-byte no-op.
|
|
1024
1030
|
return doc;
|
|
1025
1031
|
}
|
|
1032
|
+
const existingPack = provenanceOf(existing);
|
|
1033
|
+
const incomingPack = provenanceOf(def);
|
|
1034
|
+
if (existingPack && existingPack === incomingPack) {
|
|
1035
|
+
servers[name] = def;
|
|
1036
|
+
return { ...doc, mcpServers: servers };
|
|
1037
|
+
}
|
|
1026
1038
|
throw new McpNameCollisionError(`MCP server "${name}" is already defined in ~/.claude.json with a different ` +
|
|
1027
1039
|
'definition — refusing to overwrite. Remove or rename the existing server, ' +
|
|
1028
1040
|
'or uninstall the conflicting pack, then re-install.');
|
|
@@ -1302,7 +1314,7 @@ export const CODEX_MCP_SERVERS_KEY = 'mcp_servers';
|
|
|
1302
1314
|
/**
|
|
1303
1315
|
* Serialize EXACTLY ONE server table (`[mcp_servers.<name>]` + its sub-tables)
|
|
1304
1316
|
* by wrapping the def under `mcp_servers.<name>` and round-tripping it through
|
|
1305
|
-
* smol-toml. Used
|
|
1317
|
+
* smol-toml. Used for appending new servers, replacing an owned table, and the
|
|
1306
1318
|
* collision/equality comparison. The output is deterministic for a given def so a
|
|
1307
1319
|
* re-install produces byte-identical text (idempotency depends on it).
|
|
1308
1320
|
*/
|
|
@@ -1313,15 +1325,15 @@ export function serializeOneCodexServer(name, def) {
|
|
|
1313
1325
|
* The Codex `ConfigFormat`. `parse` round-trips the file through smol-toml
|
|
1314
1326
|
* (throwing `TomlError` on malformed NON-empty input → the substrate maps it to
|
|
1315
1327
|
* {@link ConfigParseError}, FAIL-CLOSED). `serialize` is comment-preserving: when
|
|
1316
|
-
* the doc carries `originalText` (the file pre-existed) it APPENDS
|
|
1317
|
-
*
|
|
1318
|
-
* whole value
|
|
1319
|
-
*
|
|
1328
|
+
* the doc carries `originalText` (the file pre-existed) it APPENDS a new server
|
|
1329
|
+
* table or replaces only a same-pack owned table, rather than re-serializing the
|
|
1330
|
+
* whole value. Only a fresh file (`null`) is serialized from scratch.
|
|
1331
|
+
* `assertEntry` checks `mcp_servers.<name>` is present.
|
|
1320
1332
|
*
|
|
1321
|
-
* Because `serialize` needs
|
|
1322
|
-
*
|
|
1323
|
-
*
|
|
1324
|
-
*
|
|
1333
|
+
* Because `serialize` needs to know WHICH server to append or replace, the merge
|
|
1334
|
+
* fn ({@link mergeCodexServer}) records the operation on the doc. For an
|
|
1335
|
+
* idempotent no-op (server already present, def-equal) the merge returns the doc
|
|
1336
|
+
* UNCHANGED, so
|
|
1325
1337
|
* `serialize` reproduces the original text exactly (no write happens).
|
|
1326
1338
|
*/
|
|
1327
1339
|
export const codexConfigFormat = {
|
|
@@ -1332,7 +1344,16 @@ export const codexConfigFormat = {
|
|
|
1332
1344
|
return { value, originalText: text };
|
|
1333
1345
|
},
|
|
1334
1346
|
serialize(doc) {
|
|
1335
|
-
const
|
|
1347
|
+
const pendingDoc = doc;
|
|
1348
|
+
const pendingUpdate = pendingDoc[PENDING_UPDATE];
|
|
1349
|
+
if (doc.originalText !== null && pendingUpdate) {
|
|
1350
|
+
const updated = replaceCodexTable(doc.originalText, pendingUpdate.name, pendingUpdate.def);
|
|
1351
|
+
if (updated === null) {
|
|
1352
|
+
throw new McpNameCollisionError(codexCollisionMessage(pendingUpdate.name), pendingUpdate.diagnostics);
|
|
1353
|
+
}
|
|
1354
|
+
return updated;
|
|
1355
|
+
}
|
|
1356
|
+
const pending = pendingDoc[PENDING_APPEND];
|
|
1336
1357
|
if (doc.originalText !== null && pending) {
|
|
1337
1358
|
// APPEND path: keep the original bytes verbatim (comments + every existing
|
|
1338
1359
|
// table/sub-table preserved), then append the one new server table.
|
|
@@ -1362,6 +1383,7 @@ export const codexConfigFormat = {
|
|
|
1362
1383
|
};
|
|
1363
1384
|
/** Symbol carrying the pending append on a {@link CodexTomlDoc} (merge → serialize). */
|
|
1364
1385
|
const PENDING_APPEND = Symbol('hqPendingCodexAppend');
|
|
1386
|
+
const PENDING_UPDATE = Symbol('hqPendingCodexUpdate');
|
|
1365
1387
|
/** Return a copy of `value` with `mcp_servers.<name> = def` set (used for fresh-file serialize). */
|
|
1366
1388
|
function withServer(value, name, def) {
|
|
1367
1389
|
const servers = { ...(value[CODEX_MCP_SERVERS_KEY] ?? {}) };
|
|
@@ -1384,6 +1406,171 @@ export function appendCodexTable(originalText, name, def) {
|
|
|
1384
1406
|
const body = block.endsWith('\n') ? block : `${block}\n`;
|
|
1385
1407
|
return `${trimmed}\n\n${body}`;
|
|
1386
1408
|
}
|
|
1409
|
+
/** Return the decoded table path for a valid TOML table header, or null. */
|
|
1410
|
+
function codexTablePath(headerLine) {
|
|
1411
|
+
try {
|
|
1412
|
+
const value = parseToml(headerLine);
|
|
1413
|
+
const matches = [];
|
|
1414
|
+
const visit = (current, pathParts) => {
|
|
1415
|
+
if (current === null || typeof current !== 'object')
|
|
1416
|
+
return;
|
|
1417
|
+
if (Array.isArray(current)) {
|
|
1418
|
+
current.forEach((item, index) => visit(item, [...pathParts, String(index)]));
|
|
1419
|
+
return;
|
|
1420
|
+
}
|
|
1421
|
+
const table = current;
|
|
1422
|
+
const entries = Object.entries(table);
|
|
1423
|
+
if (entries.length === 0) {
|
|
1424
|
+
matches.push(pathParts);
|
|
1425
|
+
return;
|
|
1426
|
+
}
|
|
1427
|
+
for (const [key, child] of entries) {
|
|
1428
|
+
visit(child, [...pathParts, key]);
|
|
1429
|
+
}
|
|
1430
|
+
};
|
|
1431
|
+
visit(value, []);
|
|
1432
|
+
return matches.length === 1 ? matches[0] : null;
|
|
1433
|
+
}
|
|
1434
|
+
catch {
|
|
1435
|
+
// The caller has already parsed the whole document. Ordinary values are
|
|
1436
|
+
// not table headers.
|
|
1437
|
+
return null;
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
/**
|
|
1441
|
+
* Locate real table headers without mistaking bracket-like text in TOML values
|
|
1442
|
+
* for a header. Parsing the preceding prefix ensures a candidate is not inside
|
|
1443
|
+
* an unfinished multiline string or array; smol-toml decodes quoted key paths.
|
|
1444
|
+
*/
|
|
1445
|
+
function codexTomlSections(text) {
|
|
1446
|
+
const headers = [];
|
|
1447
|
+
let offset = 0;
|
|
1448
|
+
for (const line of text.match(/[^\n]*(?:\n|$)/g) ?? []) {
|
|
1449
|
+
if (line.length === 0)
|
|
1450
|
+
continue;
|
|
1451
|
+
const content = line.replace(/\r?\n$/, '');
|
|
1452
|
+
if (content.trimStart().startsWith('[')) {
|
|
1453
|
+
try {
|
|
1454
|
+
parseToml(text.slice(0, offset));
|
|
1455
|
+
const path = codexTablePath(content);
|
|
1456
|
+
if (path)
|
|
1457
|
+
headers.push({ start: offset, path });
|
|
1458
|
+
}
|
|
1459
|
+
catch {
|
|
1460
|
+
// The prefix is incomplete TOML, so this bracket is part of a value.
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
offset += line.length;
|
|
1464
|
+
}
|
|
1465
|
+
return headers.map((header, index) => {
|
|
1466
|
+
const nextStart = headers[index + 1]?.start ?? text.length;
|
|
1467
|
+
let end = nextStart;
|
|
1468
|
+
const trailingLines = text.slice(header.start, nextStart).match(/[^\n]*(?:\n|$)/g) ?? [];
|
|
1469
|
+
for (let lineIndex = trailingLines.length - 1; lineIndex >= 0; lineIndex -= 1) {
|
|
1470
|
+
const content = trailingLines[lineIndex].replace(/\r?\n$/, '');
|
|
1471
|
+
if (content.trim() === '' || content.trimStart().startsWith('#')) {
|
|
1472
|
+
end -= trailingLines[lineIndex].length;
|
|
1473
|
+
}
|
|
1474
|
+
else {
|
|
1475
|
+
break;
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
return { ...header, end };
|
|
1479
|
+
});
|
|
1480
|
+
}
|
|
1481
|
+
/** Extract comment text outside TOML strings so updating an owned table drops no user notes. */
|
|
1482
|
+
function codexTomlComments(text) {
|
|
1483
|
+
const comments = [];
|
|
1484
|
+
let multiline = null;
|
|
1485
|
+
let lineOffset = 0;
|
|
1486
|
+
for (const lineWithEnding of text.match(/[^\n]*(?:\n|$)/g) ?? []) {
|
|
1487
|
+
if (lineWithEnding.length === 0)
|
|
1488
|
+
continue;
|
|
1489
|
+
const line = lineWithEnding.replace(/\r?\n$/, '');
|
|
1490
|
+
let quote = null;
|
|
1491
|
+
let index = 0;
|
|
1492
|
+
while (index < line.length) {
|
|
1493
|
+
if (multiline) {
|
|
1494
|
+
const delimiter = multiline === 'basic' ? '"""' : "'''";
|
|
1495
|
+
if (multiline === 'basic' && line[index] === '\\') {
|
|
1496
|
+
index += 2;
|
|
1497
|
+
}
|
|
1498
|
+
else if (line.startsWith(delimiter, index)) {
|
|
1499
|
+
multiline = null;
|
|
1500
|
+
index += 3;
|
|
1501
|
+
}
|
|
1502
|
+
else {
|
|
1503
|
+
index += 1;
|
|
1504
|
+
}
|
|
1505
|
+
continue;
|
|
1506
|
+
}
|
|
1507
|
+
if (quote) {
|
|
1508
|
+
const closing = quote === 'basic' ? '"' : "'";
|
|
1509
|
+
if (quote === 'basic' && line[index] === '\\') {
|
|
1510
|
+
index += 2;
|
|
1511
|
+
}
|
|
1512
|
+
else if (line[index] === closing) {
|
|
1513
|
+
quote = null;
|
|
1514
|
+
index += 1;
|
|
1515
|
+
}
|
|
1516
|
+
else {
|
|
1517
|
+
index += 1;
|
|
1518
|
+
}
|
|
1519
|
+
continue;
|
|
1520
|
+
}
|
|
1521
|
+
if (line[index] === '#') {
|
|
1522
|
+
comments.push({ offset: lineOffset + index, text: line.slice(index) });
|
|
1523
|
+
break;
|
|
1524
|
+
}
|
|
1525
|
+
if (line.startsWith('"""', index)) {
|
|
1526
|
+
multiline = 'basic';
|
|
1527
|
+
index += 3;
|
|
1528
|
+
}
|
|
1529
|
+
else if (line.startsWith("'''", index)) {
|
|
1530
|
+
multiline = 'literal';
|
|
1531
|
+
index += 3;
|
|
1532
|
+
}
|
|
1533
|
+
else if (line[index] === '"') {
|
|
1534
|
+
quote = 'basic';
|
|
1535
|
+
index += 1;
|
|
1536
|
+
}
|
|
1537
|
+
else if (line[index] === "'") {
|
|
1538
|
+
quote = 'literal';
|
|
1539
|
+
index += 1;
|
|
1540
|
+
}
|
|
1541
|
+
else {
|
|
1542
|
+
index += 1;
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
lineOffset += lineWithEnding.length;
|
|
1546
|
+
}
|
|
1547
|
+
return comments;
|
|
1548
|
+
}
|
|
1549
|
+
/** Replace all sections for one same-pack server while retaining unrelated bytes and comments. */
|
|
1550
|
+
function replaceCodexTable(originalText, name, def) {
|
|
1551
|
+
const sections = codexTomlSections(originalText);
|
|
1552
|
+
const target = sections.filter((section) => section.path[0] === CODEX_MCP_SERVERS_KEY && section.path[1] === name);
|
|
1553
|
+
if (!target.some((section) => section.path.length === 2))
|
|
1554
|
+
return null;
|
|
1555
|
+
const newline = originalText.includes('\r\n') ? '\r\n' : '\n';
|
|
1556
|
+
const comments = codexTomlComments(originalText)
|
|
1557
|
+
.filter((comment) => target.some((section) => comment.offset >= section.start && comment.offset < section.end))
|
|
1558
|
+
.map((comment) => comment.text);
|
|
1559
|
+
let output = '';
|
|
1560
|
+
let cursor = 0;
|
|
1561
|
+
let inserted = false;
|
|
1562
|
+
for (const section of target) {
|
|
1563
|
+
output += originalText.slice(cursor, section.start);
|
|
1564
|
+
if (!inserted) {
|
|
1565
|
+
output += serializeOneCodexServer(name, def).replace(/\n/g, newline);
|
|
1566
|
+
if (comments.length > 0)
|
|
1567
|
+
output += comments.join(newline) + newline;
|
|
1568
|
+
inserted = true;
|
|
1569
|
+
}
|
|
1570
|
+
cursor = section.end;
|
|
1571
|
+
}
|
|
1572
|
+
return inserted ? output + originalText.slice(cursor) : null;
|
|
1573
|
+
}
|
|
1387
1574
|
/**
|
|
1388
1575
|
* Build the Codex server table emitted as `[mcp_servers.<name>]`: pass the
|
|
1389
1576
|
* transport fields through, resolve `${secret:}` in every argument/header/env value
|
|
@@ -1447,9 +1634,10 @@ export function buildCodexToolsTable(tools) {
|
|
|
1447
1634
|
* - name PRESENT, def-equal (provenance-ignored) → NO-OP (idempotent re-install /
|
|
1448
1635
|
* PARTIAL self-heal): return the doc UNCHANGED with no pending append, so the
|
|
1449
1636
|
* original text round-trips byte-for-byte.
|
|
1450
|
-
* - name PRESENT, def-DIFFERS →
|
|
1451
|
-
*
|
|
1452
|
-
*
|
|
1637
|
+
* - name PRESENT, def-DIFFERS, same `_hqPack` owner → update that owned table
|
|
1638
|
+
* while preserving unrelated TOML bytes and comments.
|
|
1639
|
+
* - name PRESENT, def-DIFFERS, any other owner → {@link McpNameCollisionError}
|
|
1640
|
+
* ABORT with value-free diagnostics.
|
|
1453
1641
|
*
|
|
1454
1642
|
* Never touches any OTHER server table or any other top-level table.
|
|
1455
1643
|
*/
|
|
@@ -1463,9 +1651,23 @@ export function mergeCodexServer(name, def) {
|
|
|
1463
1651
|
// so serialize reproduces the original text exactly.
|
|
1464
1652
|
return doc;
|
|
1465
1653
|
}
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1654
|
+
const diagnostics = codexCollisionDiagnostics(name, existing, def);
|
|
1655
|
+
const existingPack = isTomlTable(existing)
|
|
1656
|
+
? existing[HQ_PACK_PROVENANCE_KEY]
|
|
1657
|
+
: undefined;
|
|
1658
|
+
if (doc.originalText !== null &&
|
|
1659
|
+
isTomlTable(existing) &&
|
|
1660
|
+
typeof existingPack === 'string' &&
|
|
1661
|
+
existingPack.length > 0 &&
|
|
1662
|
+
existingPack === def[HQ_PACK_PROVENANCE_KEY]) {
|
|
1663
|
+
const next = {
|
|
1664
|
+
value: withServer(doc.value, name, def),
|
|
1665
|
+
originalText: doc.originalText,
|
|
1666
|
+
[PENDING_UPDATE]: { name, def, diagnostics },
|
|
1667
|
+
};
|
|
1668
|
+
return next;
|
|
1669
|
+
}
|
|
1670
|
+
throw new McpNameCollisionError(codexCollisionMessage(name), diagnostics);
|
|
1469
1671
|
}
|
|
1470
1672
|
// Insert into the value (for verify-after-write) AND record the pending append
|
|
1471
1673
|
// (for comment-preserving serialize).
|
|
@@ -1478,6 +1680,41 @@ export function mergeCodexServer(name, def) {
|
|
|
1478
1680
|
return next;
|
|
1479
1681
|
};
|
|
1480
1682
|
}
|
|
1683
|
+
function isTomlTable(value) {
|
|
1684
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
1685
|
+
}
|
|
1686
|
+
function codexCollisionMessage(name) {
|
|
1687
|
+
return `MCP server "${name}" is already defined in ~/.codex/config.toml with a different ` +
|
|
1688
|
+
'definition — refusing to overwrite. Remove or rename the existing server, ' +
|
|
1689
|
+
'or uninstall the conflicting pack, then re-install.';
|
|
1690
|
+
}
|
|
1691
|
+
function codexCollisionDiagnostics(name, existing, incoming) {
|
|
1692
|
+
const existingTable = isTomlTable(existing) ? existing : {};
|
|
1693
|
+
const owner = typeof existingTable[HQ_PACK_PROVENANCE_KEY] === 'string' &&
|
|
1694
|
+
existingTable[HQ_PACK_PROVENANCE_KEY].length > 0
|
|
1695
|
+
? 'hq'
|
|
1696
|
+
: 'unknown';
|
|
1697
|
+
const keys = new Set([...Object.keys(existingTable), ...Object.keys(incoming)]);
|
|
1698
|
+
keys.delete(HQ_PACK_PROVENANCE_KEY);
|
|
1699
|
+
const fields = new Set();
|
|
1700
|
+
for (const key of keys) {
|
|
1701
|
+
const existingHasKey = Object.prototype.hasOwnProperty.call(existingTable, key);
|
|
1702
|
+
const incomingHasKey = Object.prototype.hasOwnProperty.call(incoming, key);
|
|
1703
|
+
if (existingHasKey === incomingHasKey &&
|
|
1704
|
+
canonicalJson(existingTable[key]) === canonicalJson(incoming[key])) {
|
|
1705
|
+
continue;
|
|
1706
|
+
}
|
|
1707
|
+
fields.add(key === 'command' || key === 'args' || key === 'env' || key === 'url'
|
|
1708
|
+
? key
|
|
1709
|
+
: 'other');
|
|
1710
|
+
}
|
|
1711
|
+
return {
|
|
1712
|
+
client: 'codex',
|
|
1713
|
+
serverName: name,
|
|
1714
|
+
existingEntryOwner: owner,
|
|
1715
|
+
diffFields: [...fields].sort(),
|
|
1716
|
+
};
|
|
1717
|
+
}
|
|
1481
1718
|
/**
|
|
1482
1719
|
* Emit ONE server into the GLOBAL Codex surface (`~/.codex/config.toml`
|
|
1483
1720
|
* `[mcp_servers.<name>]`) via {@link writeConfigAtomic}. A FIRST-CLASS SKIP when the
|
package/dist/commands/mesh.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* require hq-pack-work-mesh on disk and does not start MQTT listen.
|
|
6
6
|
* Distinct from `hq doctor` (hook guardrails).
|
|
7
7
|
*/
|
|
8
|
+
import { resolve as resolvePath } from "node:path";
|
|
8
9
|
import * as os from "node:os";
|
|
9
10
|
import { readFileSync } from "node:fs";
|
|
10
11
|
import { findHqRoot } from "../utils/manifest.js";
|
|
@@ -42,6 +43,7 @@ import { findLocalPrd, listNeedsProjectSessions } from "../lib/work-context/bind
|
|
|
42
43
|
import { bindCallingSession, formatSessionBoundLine } from "../lib/work-context/bind-calling-session.js";
|
|
43
44
|
import { rebindNeedsProjectOnPrompt } from "../lib/work-context/prompt-rebind.js";
|
|
44
45
|
import { planPrdSync } from "../lib/work-context/prd-sync.js";
|
|
46
|
+
import { reserveSessionSeq } from "../lib/mesh/live/session-seq.js";
|
|
45
47
|
import { WORK_CONTEXT_CONTRACT_VERSION } from "../lib/work-context/contract.js";
|
|
46
48
|
import { loadObservationFromFile, markUntracked, parseObservationJson, reconcileObservation, } from "../lib/work-context/reconcile.js";
|
|
47
49
|
import { resolveCompany } from "../lib/work-context/company.js";
|
|
@@ -565,6 +567,15 @@ async function runPrdSync(opts) {
|
|
|
565
567
|
console.error("work-mesh: prd-sync has changes but no token; snapshot not advanced");
|
|
566
568
|
return;
|
|
567
569
|
}
|
|
570
|
+
// Attribution evidence: without company and project the server files these
|
|
571
|
+
// as unassigned and the Board never moves.
|
|
572
|
+
const bound = readSessionState(sessionId, root);
|
|
573
|
+
const evidenceBase = {
|
|
574
|
+
...(bound?.companySlug ? { companySlug: bound.companySlug } : {}),
|
|
575
|
+
...(bound?.projectId ? { project: bound.projectId } : {}),
|
|
576
|
+
touchedPaths: [resolvePath(file)],
|
|
577
|
+
};
|
|
578
|
+
const seqFirst = reserveSessionSeq(sessionId, plan.events.length);
|
|
568
579
|
const events = plan.events.map((event, index) => buildMeshEmitEvent({
|
|
569
580
|
eventId: generateUlid(Date.now() + index),
|
|
570
581
|
kind: "task_status",
|
|
@@ -572,18 +583,24 @@ async function runPrdSync(opts) {
|
|
|
572
583
|
harness: "hq-sessions",
|
|
573
584
|
adapterVersion: "1.0.0",
|
|
574
585
|
at: new Date().toISOString(),
|
|
575
|
-
seq:
|
|
586
|
+
seq: seqFirst + index,
|
|
576
587
|
source: "hooks",
|
|
577
588
|
taskId: event.taskId,
|
|
578
589
|
status: event.status,
|
|
579
590
|
summary: event.status === "done" ? `${event.taskId} passes` : `${event.taskId} in progress`,
|
|
591
|
+
evidence: { ...evidenceBase, task: event.taskId },
|
|
580
592
|
}));
|
|
581
593
|
const poster = createEmitPoster({ token, baseUrl: resolveVaultApiBase(process.env) });
|
|
582
|
-
await emitEvents({
|
|
594
|
+
const emitted = await emitEvents({
|
|
583
595
|
workMeshRoot: workMeshHomeRoot(),
|
|
584
596
|
poster,
|
|
585
597
|
newEvents: events,
|
|
586
598
|
});
|
|
599
|
+
if (emitted.unassigned > 0 || emitted.rejected > 0) {
|
|
600
|
+
console.error(`work-mesh: prd-sync sent ${events.length}; unassigned=${emitted.unassigned} rejected=${emitted.rejected}`);
|
|
601
|
+
for (const line of formatEmitRejectionLines(emitted.rejections))
|
|
602
|
+
console.error(line);
|
|
603
|
+
}
|
|
587
604
|
plan.commit();
|
|
588
605
|
if (plan.taskId) {
|
|
589
606
|
updateSessionState(sessionId, root, (current) => {
|
package/dist/lib/mesh/api.d.ts
CHANGED
|
@@ -78,7 +78,7 @@ export declare function listActiveMembershipCompanies(token: string): Promise<Me
|
|
|
78
78
|
* Returns null when the caller is not an active member. Throws on transport /
|
|
79
79
|
* auth failures so the caller can surface DEFAULT_COMPANY_UNAVAILABLE.
|
|
80
80
|
*/
|
|
81
|
-
export declare function resolveActiveMembershipCompany(token: string, slug: string): Promise<MeshCompany | null>;
|
|
81
|
+
export declare function resolveActiveMembershipCompany(token: string, slug: string, signal?: AbortSignal): Promise<MeshCompany | null>;
|
|
82
82
|
export interface RequireTokenOptions {
|
|
83
83
|
/**
|
|
84
84
|
* False from detached or unattended callers (the lane watcher). A person
|
package/dist/lib/mesh/api.js
CHANGED
|
@@ -113,11 +113,15 @@ export async function listActiveMembershipCompanies(token) {
|
|
|
113
113
|
* Returns null when the caller is not an active member. Throws on transport /
|
|
114
114
|
* auth failures so the caller can surface DEFAULT_COMPANY_UNAVAILABLE.
|
|
115
115
|
*/
|
|
116
|
-
export async function resolveActiveMembershipCompany(token, slug) {
|
|
116
|
+
export async function resolveActiveMembershipCompany(token, slug, signal) {
|
|
117
117
|
const trimmed = slug.trim();
|
|
118
118
|
if (!trimmed)
|
|
119
119
|
return null;
|
|
120
|
-
|
|
120
|
+
// An aborted lookup is not "not a member". Throw so callers defer.
|
|
121
|
+
if (signal?.aborted) {
|
|
122
|
+
throw new Error("membership lookup aborted");
|
|
123
|
+
}
|
|
124
|
+
const payload = asRecord(await meshJson(token, "/membership/me", { signal }));
|
|
121
125
|
if (!payload || !Array.isArray(payload.memberships)) {
|
|
122
126
|
throw new Error("Malformed membership response: expected memberships array");
|
|
123
127
|
}
|
|
@@ -165,8 +165,20 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
165
165
|
? readSessionState(input.sessionId, ctxRoot)
|
|
166
166
|
: null);
|
|
167
167
|
const slug = input.companySlug ?? state?.companySlug;
|
|
168
|
+
// A uid alone is enough to ask /membership/me. Missing slug is not a
|
|
169
|
+
// denial. A device-default session whose company was cleared can be
|
|
170
|
+
// checked against the configured default; that is the same company the
|
|
171
|
+
// daemon assigned before the false denial.
|
|
172
|
+
const configuredDefault = input.deviceDefault
|
|
173
|
+
? readDeviceConfig({ root: ctxRoot }).defaultCompany
|
|
174
|
+
: undefined;
|
|
175
|
+
const defaultCompany = configuredDefault?.enabled ? configuredDefault : undefined;
|
|
176
|
+
const usingDefaultFallback = !slug && !state?.companyUid && Boolean(defaultCompany?.slug || defaultCompany?.uid);
|
|
177
|
+
const lookupKey = slug || state?.companyUid || (usingDefaultFallback
|
|
178
|
+
? (defaultCompany?.slug || defaultCompany?.uid)
|
|
179
|
+
: undefined);
|
|
168
180
|
let authorization;
|
|
169
|
-
if (!
|
|
181
|
+
if (!lookupKey) {
|
|
170
182
|
authorization = { status: "denied" };
|
|
171
183
|
}
|
|
172
184
|
else {
|
|
@@ -175,7 +187,14 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
175
187
|
try {
|
|
176
188
|
authorization = await Promise.race([
|
|
177
189
|
(async () => {
|
|
178
|
-
const membership = await resolveMembership(await getToken(),
|
|
190
|
+
const membership = await resolveMembership(await getToken(), lookupKey, controller.signal);
|
|
191
|
+
// Timeout wins even if the probe then returns null.
|
|
192
|
+
if (controller.signal.aborted) {
|
|
193
|
+
return {
|
|
194
|
+
status: "unavailable",
|
|
195
|
+
error: `membership lookup timed out after ${DEVICE_DEFAULT_MEMBERSHIP_TIMEOUT_MS}ms`,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
179
198
|
if (membership)
|
|
180
199
|
return { status: "authorized", uid: membership.companyUid };
|
|
181
200
|
// A machine token often cannot read /membership/me (401/403, or an
|
|
@@ -221,8 +240,43 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
221
240
|
if (authorization.status === "authorized" &&
|
|
222
241
|
(!input.companyUid || input.companyUid === authorization.uid) &&
|
|
223
242
|
(!state?.companyUid || state.companyUid === authorization.uid)) {
|
|
243
|
+
// A prior false denial cleared the company and left needs_company.
|
|
244
|
+
// Membership just confirmed the company, so put it back and let flush post.
|
|
245
|
+
if (state &&
|
|
246
|
+
state.companyConfidence === "device_default" &&
|
|
247
|
+
state.contextStatus === "needs_company") {
|
|
248
|
+
const restoredSlug = state.companySlug || slug || defaultCompany?.slug;
|
|
249
|
+
const restoredUid = authorization.uid;
|
|
250
|
+
updateSessionState(state.sessionId, ctxRoot, (current) => {
|
|
251
|
+
if (!current || current.contextStatus !== "needs_company")
|
|
252
|
+
return null;
|
|
253
|
+
if (current.companyUid && current.companyUid !== restoredUid)
|
|
254
|
+
return null;
|
|
255
|
+
if (current.companySlug &&
|
|
256
|
+
restoredSlug &&
|
|
257
|
+
!sameCompanyValue(current.companySlug, restoredSlug)) {
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
return mergeLocalSessionFields({
|
|
261
|
+
...current,
|
|
262
|
+
companyUid: restoredUid,
|
|
263
|
+
...(restoredSlug ? { companySlug: restoredSlug } : {}),
|
|
264
|
+
contextStatus: current.projectId
|
|
265
|
+
? (current.taskId ? "bound" : "needs_task")
|
|
266
|
+
: "needs_project",
|
|
267
|
+
updatedAt: now().toISOString(),
|
|
268
|
+
}, current, { preserveCompanyBinding: false });
|
|
269
|
+
});
|
|
270
|
+
}
|
|
224
271
|
return authorization;
|
|
225
272
|
}
|
|
273
|
+
// Already stripped. Do not revoke, count, or log this again on every flush.
|
|
274
|
+
if (state &&
|
|
275
|
+
state.contextStatus === "needs_company" &&
|
|
276
|
+
!state.companyUid &&
|
|
277
|
+
!state.companySlug) {
|
|
278
|
+
return { status: "denied" };
|
|
279
|
+
}
|
|
226
280
|
try {
|
|
227
281
|
invalidateDefaultCompanyMemberships({ root: ctxRoot, now });
|
|
228
282
|
}
|
|
@@ -16,6 +16,11 @@ export declare const HELD_TTL_MS: number;
|
|
|
16
16
|
export declare const HELD_RETRY_INTERVAL_MS: number;
|
|
17
17
|
/** Cap held.jsonl size; oldest overflow is dead-lettered with HELD_OVERFLOW. */
|
|
18
18
|
export declare const HELD_MAX_LINES = 20000;
|
|
19
|
+
/**
|
|
20
|
+
* Re-check an unbound session against the project catalog at most this often.
|
|
21
|
+
* The catalog itself is also cached inside bind-best.
|
|
22
|
+
*/
|
|
23
|
+
export declare const PROJECT_REBIND_INTERVAL_MS = 60000;
|
|
19
24
|
export type HeldReason = "STATE_ABSENT" | "NEEDS_COMPANY" | "COMPANY_CONFLICT" | "NEEDS_PROJECT" | "HELD_EXPIRED" | "HELD_OVERFLOW";
|
|
20
25
|
export interface HeldLineRecord {
|
|
21
26
|
/** Original spool event fields. */
|
|
@@ -46,6 +51,9 @@ export interface FlushDeps {
|
|
|
46
51
|
log?: (message: string) => void;
|
|
47
52
|
/** Authoritative gate for state derived from a device default. */
|
|
48
53
|
authorizeDeviceDefaultTenant?: (state: SessionStateFile) => Promise<boolean>;
|
|
54
|
+
/** Passed through to bind-best (catalog cache root and HQ_WORK_MESH_CACHE). */
|
|
55
|
+
env?: NodeJS.ProcessEnv;
|
|
56
|
+
home?: string;
|
|
49
57
|
}
|
|
50
58
|
export interface FlushSummary {
|
|
51
59
|
claimed: number;
|
|
@@ -12,6 +12,7 @@ import * as fs from "node:fs";
|
|
|
12
12
|
import * as path from "node:path";
|
|
13
13
|
import { resolveCompany } from "../../work-context/company.js";
|
|
14
14
|
import { deriveRemoteOwnerSlug, deriveRepoIdentityKey, } from "../../work-context/repo-remote.js";
|
|
15
|
+
import { rebindNeedsProjectOnPrompt } from "../../work-context/prompt-rebind.js";
|
|
15
16
|
import { readSessionState, updateSessionState } from "../../work-context/state.js";
|
|
16
17
|
import { repairAckedRegisterStates } from "../../work-context/outbox.js";
|
|
17
18
|
import { workContextRoot } from "../../work-context/paths.js";
|
|
@@ -25,6 +26,12 @@ export const HELD_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
|
25
26
|
export const HELD_RETRY_INTERVAL_MS = 5 * 60 * 1000;
|
|
26
27
|
/** Cap held.jsonl size; oldest overflow is dead-lettered with HELD_OVERFLOW. */
|
|
27
28
|
export const HELD_MAX_LINES = 20_000;
|
|
29
|
+
/**
|
|
30
|
+
* Re-check an unbound session against the project catalog at most this often.
|
|
31
|
+
* The catalog itself is also cached inside bind-best.
|
|
32
|
+
*/
|
|
33
|
+
export const PROJECT_REBIND_INTERVAL_MS = 60_000;
|
|
34
|
+
const lastProjectRebindAtMs = new Map();
|
|
28
35
|
function parseLine(line) {
|
|
29
36
|
try {
|
|
30
37
|
const v = JSON.parse(line);
|
|
@@ -224,18 +231,25 @@ export async function flushSessionEvents(deps) {
|
|
|
224
231
|
};
|
|
225
232
|
const loggedProject404 = new Set();
|
|
226
233
|
for (const [sessionId, items] of bySession) {
|
|
227
|
-
|
|
234
|
+
let state = readSessionState(sessionId, deps.workContextRoot);
|
|
228
235
|
if (state?.companyConfidence === "device_default" &&
|
|
229
|
-
deps.authorizeDeviceDefaultTenant
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
+
deps.authorizeDeviceDefaultTenant) {
|
|
237
|
+
const allowed = await deps.authorizeDeviceDefaultTenant(state);
|
|
238
|
+
// Authorization may restore a company the gate previously cleared.
|
|
239
|
+
state = readSessionState(sessionId, deps.workContextRoot) ?? state;
|
|
240
|
+
if (!allowed) {
|
|
241
|
+
const heldAt = now().toISOString();
|
|
242
|
+
for (const item of items) {
|
|
243
|
+
appendHeldLine(heldEnvelope(item.event, "NEEDS_COMPANY", item.heldAt ?? heldAt), deps.workMeshRoot);
|
|
244
|
+
summary.held += 1;
|
|
245
|
+
markDisposed(item.event);
|
|
246
|
+
}
|
|
247
|
+
continue;
|
|
236
248
|
}
|
|
237
|
-
continue;
|
|
238
249
|
}
|
|
250
|
+
// After the company gate (which may restore a cleared company), try to
|
|
251
|
+
// bind a needs_project session to an existing Board before classifying.
|
|
252
|
+
state = rebindUnboundSession(sessionId, items, state, deps, nowMs);
|
|
239
253
|
const decision = classifySession(state);
|
|
240
254
|
if (decision.action === "drop") {
|
|
241
255
|
appendDroppedReceipt({
|
|
@@ -502,6 +516,54 @@ function canonicalizeForRestore(event) {
|
|
|
502
516
|
}
|
|
503
517
|
return out;
|
|
504
518
|
}
|
|
519
|
+
function eventText(event, key) {
|
|
520
|
+
const value = event[key];
|
|
521
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* Before a needs_project (or unresolved-with-company) batch is classified,
|
|
525
|
+
* run the prompt-submit bind-best refinement. A bind updates the session
|
|
526
|
+
* file so the batch posts with that projectId. Ties stay unbound. At most
|
|
527
|
+
* one attempt per session per minute; failures stay on the existing state.
|
|
528
|
+
*/
|
|
529
|
+
function rebindUnboundSession(sessionId, items, state, deps, nowMs) {
|
|
530
|
+
if (!state?.companyUid)
|
|
531
|
+
return state;
|
|
532
|
+
if (state.contextStatus !== "needs_project" && state.contextStatus !== "unresolved") {
|
|
533
|
+
return state;
|
|
534
|
+
}
|
|
535
|
+
const key = `${deps.workContextRoot}\0${sessionId}`;
|
|
536
|
+
const previous = lastProjectRebindAtMs.get(key);
|
|
537
|
+
if (previous !== undefined && nowMs - previous < PROJECT_REBIND_INTERVAL_MS) {
|
|
538
|
+
return state;
|
|
539
|
+
}
|
|
540
|
+
lastProjectRebindAtMs.set(key, nowMs);
|
|
541
|
+
const sample = items[0]?.event ?? {};
|
|
542
|
+
const started = Date.now();
|
|
543
|
+
try {
|
|
544
|
+
rebindNeedsProjectOnPrompt({
|
|
545
|
+
sessionId,
|
|
546
|
+
root: deps.workContextRoot,
|
|
547
|
+
hqRoot: eventText(sample, "hqRoot"),
|
|
548
|
+
cwd: eventText(sample, "cwd"),
|
|
549
|
+
companySlug: state.companySlug ?? eventText(sample, "companySlug"),
|
|
550
|
+
companyUid: state.companyUid,
|
|
551
|
+
hint: eventText(sample, "project"),
|
|
552
|
+
env: deps.env,
|
|
553
|
+
home: deps.home,
|
|
554
|
+
nowMs,
|
|
555
|
+
now: deps.now,
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
catch (err) {
|
|
559
|
+
deps.log?.(`work-mesh: session ${sessionId} project rebind failed: ${err instanceof Error ? err.message : "unknown"}`);
|
|
560
|
+
}
|
|
561
|
+
const elapsed = Date.now() - started;
|
|
562
|
+
if (elapsed > 300) {
|
|
563
|
+
deps.log?.(`work-mesh: session ${sessionId} project rebind took ${elapsed}ms`);
|
|
564
|
+
}
|
|
565
|
+
return readSessionState(sessionId, deps.workContextRoot) ?? state;
|
|
566
|
+
}
|
|
505
567
|
function classifySession(state) {
|
|
506
568
|
if (!state) {
|
|
507
569
|
return { action: "hold", heldReason: "STATE_ABSENT" };
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare function sessionSeqDir(env?: NodeJS.ProcessEnv): string;
|
|
2
|
+
/** Reserve `count` consecutive seq numbers; returns the first one. */
|
|
3
|
+
export declare function reserveSessionSeq(sessionId: string, count: number, env?: NodeJS.ProcessEnv): number;
|
|
4
|
+
//# sourceMappingURL=session-seq.d.ts.map
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared per-session event sequence, the same file the core hooks use
|
|
3
|
+
* (`~/.hq/work-mesh/seq/<sid>`, see core/scripts/lib/work-mesh-live-hook.sh).
|
|
4
|
+
* Every emitter for a session must take numbers from here, or the server
|
|
5
|
+
* treats the new events as duplicates of earlier ones and drops them.
|
|
6
|
+
*/
|
|
7
|
+
import * as fs from "node:fs";
|
|
8
|
+
import * as os from "node:os";
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
export function sessionSeqDir(env = process.env) {
|
|
11
|
+
return env.WORK_MESH_SEQ_DIR?.trim() || path.join(os.homedir(), ".hq", "work-mesh", "seq");
|
|
12
|
+
}
|
|
13
|
+
/** Reserve `count` consecutive seq numbers; returns the first one. */
|
|
14
|
+
export function reserveSessionSeq(sessionId, count, env = process.env) {
|
|
15
|
+
if (!/^[A-Za-z0-9._-]+$/.test(sessionId))
|
|
16
|
+
throw new Error("invalid session id");
|
|
17
|
+
const dir = sessionSeqDir(env);
|
|
18
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
19
|
+
const file = path.join(dir, sessionId);
|
|
20
|
+
let current = 0;
|
|
21
|
+
try {
|
|
22
|
+
const raw = fs.readFileSync(file, "utf8").trim();
|
|
23
|
+
if (/^\d+$/.test(raw))
|
|
24
|
+
current = Number(raw);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
current = 0;
|
|
28
|
+
}
|
|
29
|
+
const first = current + 1;
|
|
30
|
+
fs.writeFileSync(file, `${current + Math.max(0, count)}\n`, { mode: 0o600 });
|
|
31
|
+
return first;
|
|
32
|
+
}
|
|
33
|
+
//# sourceMappingURL=session-seq.js.map
|
|
@@ -11,9 +11,17 @@ export async function closeSupersededProjectThread(input) {
|
|
|
11
11
|
return;
|
|
12
12
|
if (!input.openedThreadId || input.openedThreadId === input.canonicalThreadId)
|
|
13
13
|
return;
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
try {
|
|
15
|
+
await appendThreadEvent(input.token, input.companyUid, input.openedThreadId, "done", {
|
|
16
|
+
summary: "Superseded by the registered project thread.",
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
catch (err) {
|
|
20
|
+
// Best effort: the server may refuse (e.g. only the thread owner may mark
|
|
21
|
+
// it done). A leftover open thread must never fail registration.
|
|
22
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
23
|
+
process.stderr.write(`Work mesh: left extra thread ${input.openedThreadId} open (${reason.slice(0, 120)})\n`);
|
|
24
|
+
}
|
|
17
25
|
}
|
|
18
26
|
/**
|
|
19
27
|
* Create a missing Board. Register returns 403 PROJECT_NOT_IN_COMPANY until
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* US-040:
|
|
3
|
-
*
|
|
2
|
+
* US-040: re-run bind-best for a session still in needs_project, or
|
|
3
|
+
* unresolved once it has a company. The prompt hook and the mesh daemon
|
|
4
|
+
* flush both call this. Catalog reads are cached. Failures stay local.
|
|
4
5
|
*/
|
|
5
6
|
export type PromptRebindResult = {
|
|
6
7
|
action: "bound" | "skipped" | "unchanged";
|
|
@@ -14,7 +15,10 @@ export declare function rebindNeedsProjectOnPrompt(input: {
|
|
|
14
15
|
cwd?: string;
|
|
15
16
|
companySlug?: string;
|
|
16
17
|
companyUid?: string;
|
|
18
|
+
/** Used when the session has no projectHint (for example a spool `project` field). */
|
|
19
|
+
hint?: string;
|
|
17
20
|
env?: NodeJS.ProcessEnv;
|
|
21
|
+
home?: string;
|
|
18
22
|
nowMs?: number;
|
|
19
23
|
now?: () => Date;
|
|
20
24
|
}): PromptRebindResult;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* US-040:
|
|
3
|
-
*
|
|
2
|
+
* US-040: re-run bind-best for a session still in needs_project, or
|
|
3
|
+
* unresolved once it has a company. The prompt hook and the mesh daemon
|
|
4
|
+
* flush both call this. Catalog reads are cached. Failures stay local.
|
|
4
5
|
*/
|
|
5
6
|
import { projectIdFromCwd } from "./company.js";
|
|
6
7
|
import { readMetaProjectTask } from "./project.js";
|
|
@@ -17,13 +18,17 @@ export function rebindNeedsProjectOnPrompt(input) {
|
|
|
17
18
|
}
|
|
18
19
|
if (!state)
|
|
19
20
|
return { action: "skipped", reason: "no_state" };
|
|
20
|
-
|
|
21
|
+
const companyUid = input.companyUid ?? state.companyUid;
|
|
22
|
+
const retryable = state.contextStatus === "needs_project" ||
|
|
23
|
+
(state.contextStatus === "unresolved" && Boolean(companyUid));
|
|
24
|
+
if (!retryable) {
|
|
21
25
|
return { action: "skipped", reason: "not_needs_project" };
|
|
22
26
|
}
|
|
23
27
|
if (state.projectId)
|
|
24
28
|
return { action: "skipped", reason: "different_project" };
|
|
25
29
|
const env = input.env ?? process.env;
|
|
26
30
|
const hint = state.projectHint?.trim() ||
|
|
31
|
+
input.hint?.trim() ||
|
|
27
32
|
env.HQ_SPAWN_PROJECT?.trim() ||
|
|
28
33
|
readMetaProjectTask(input.sessionId, input.hqRoot).projectId ||
|
|
29
34
|
projectIdFromCwd(input.cwd, input.hqRoot) ||
|
|
@@ -36,9 +41,10 @@ export function rebindNeedsProjectOnPrompt(input) {
|
|
|
36
41
|
hint,
|
|
37
42
|
hqRoot: input.hqRoot,
|
|
38
43
|
companySlug: input.companySlug ?? state.companySlug,
|
|
39
|
-
companyUid
|
|
44
|
+
companyUid,
|
|
40
45
|
repoPath: input.cwd,
|
|
41
46
|
env,
|
|
47
|
+
home: input.home,
|
|
42
48
|
nowMs: input.nowMs ?? Date.now(),
|
|
43
49
|
});
|
|
44
50
|
}
|
|
@@ -52,7 +58,10 @@ export function rebindNeedsProjectOnPrompt(input) {
|
|
|
52
58
|
const reason = refined.reason;
|
|
53
59
|
const nowIso = (input.now ?? (() => new Date()))().toISOString();
|
|
54
60
|
const updated = updateSessionState(input.sessionId, input.root, (current) => {
|
|
55
|
-
if (!current || current.contextStatus !== "needs_project")
|
|
61
|
+
if (!current || (current.contextStatus !== "needs_project" && current.contextStatus !== "unresolved")) {
|
|
62
|
+
return current;
|
|
63
|
+
}
|
|
64
|
+
if (current.contextStatus === "unresolved" && !current.companyUid)
|
|
56
65
|
return current;
|
|
57
66
|
if (current.projectId && current.projectId !== projectId)
|
|
58
67
|
return current;
|
package/dist/sentry.js
CHANGED
|
@@ -7,7 +7,7 @@ import { getCachedSentryUser } from "./utils/sentry-identity.js";
|
|
|
7
7
|
import { isEpipe } from "./utils/epipe.js";
|
|
8
8
|
import { environmentalFsErrorMessage } from "./utils/environmental-error.js";
|
|
9
9
|
import { incompleteInstallCaptureContext, incompleteInstallMessage, } from "./utils/incomplete-install-error.js";
|
|
10
|
-
import { sentryFingerprintFor } from "./utils/sentry-fingerprint.js";
|
|
10
|
+
import { sentryFingerprintFor, sentryMcpCollisionContextFor, } from "./utils/sentry-fingerprint.js";
|
|
11
11
|
/**
|
|
12
12
|
* Drop broken-pipe (EPIPE) crashes before scrubbing/send. A closed downstream
|
|
13
13
|
* reader (`hq … | head`, `source <(hq …)`, a parent that exited) is normal
|
|
@@ -70,12 +70,20 @@ export function epipeAwareBeforeSend(event, hint) {
|
|
|
70
70
|
// before the fleet scrubber — so it covers EVERY capture route (the top-level
|
|
71
71
|
// boundary, the command-level captureException sites, bin/hq-auth-refresh,
|
|
72
72
|
// any uncaughtException), mirroring the EPIPE and environmental-fs drops
|
|
73
|
-
// above. The
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
73
|
+
// above. The typed error classifiers use closed allowlists. The Codex
|
|
74
|
+
// collision branch uses one fixed bucket and adds the validated server name
|
|
75
|
+
// only as a tag; it sends no config values or paths. Unclassifiable
|
|
76
|
+
// events retain Sentry's default grouping.
|
|
77
|
+
const mcpCollision = sentryMcpCollisionContextFor(hint?.originalException);
|
|
78
|
+
if (mcpCollision) {
|
|
79
|
+
event.tags = { ...event.tags, ...mcpCollision.tags };
|
|
80
|
+
event.fingerprint = mcpCollision.fingerprint;
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
const fingerprint = sentryFingerprintFor(hint?.originalException);
|
|
84
|
+
if (fingerprint)
|
|
85
|
+
event.fingerprint = fingerprint;
|
|
86
|
+
}
|
|
79
87
|
return beforeSend(event, hint);
|
|
80
88
|
}
|
|
81
89
|
/**
|
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
export interface SentryMcpCollisionContext {
|
|
2
|
+
tags: {
|
|
3
|
+
mcp_client: "codex";
|
|
4
|
+
server_name: string;
|
|
5
|
+
existing_entry_owner: "hq" | "foreign" | "unknown";
|
|
6
|
+
diff_fields: string;
|
|
7
|
+
};
|
|
8
|
+
fingerprint: string[];
|
|
9
|
+
}
|
|
10
|
+
/** Return bounded, value-free Sentry context for an instrumented Codex name collision. */
|
|
11
|
+
export declare function sentryMcpCollisionContextFor(err: unknown): SentryMcpCollisionContext | null;
|
|
1
12
|
/**
|
|
2
13
|
* Return a bounded `event.fingerprint` array for `err`, or `null` when `err`
|
|
3
14
|
* carries no discriminator from the closed allowlist (in which case the caller
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// src/utils/sentry-fingerprint.ts
|
|
2
2
|
//
|
|
3
|
-
// Compute a BOUNDED Sentry grouping fingerprint from an error, using only
|
|
4
|
-
//
|
|
5
|
-
// user-minted text. Sibling in spirit to qmd-collection-missing-error.ts
|
|
3
|
+
// Compute a BOUNDED Sentry grouping fingerprint from an error, using only
|
|
4
|
+
// closed discriminators and a fixed Codex collision bucket, never arbitrary
|
|
5
|
+
// upstream- or user-minted text. Sibling in spirit to qmd-collection-missing-error.ts
|
|
6
6
|
// (HQ-CLI-S): the same unbounded-fingerprint discipline, applied to grouping
|
|
7
7
|
// instead of to a printed line.
|
|
8
8
|
//
|
|
@@ -45,14 +45,14 @@
|
|
|
45
45
|
// grouping untouched.
|
|
46
46
|
//
|
|
47
47
|
// Two hard rules, mirroring HQ-CLI-S:
|
|
48
|
-
// 1.
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
48
|
+
// 1. BOUNDED DISCRIMINATORS. RPC/HTTP values use closed allowlists and
|
|
49
|
+
// fallback buckets. Codex collisions use one fixed client bucket. The
|
|
50
|
+
// server-name tag is restricted to 64 lowercase letters, digits,
|
|
51
|
+
// underscores or hyphens; other names share one fallback value. Owner and
|
|
52
|
+
// field tags also use closed sets.
|
|
53
|
+
// 2. NO FREE TEXT. Error messages, argv, URLs, connection ids, company slugs,
|
|
54
|
+
// config values and paths never reach a fingerprint or tags. The Codex
|
|
55
|
+
// branch uses a fixed fingerprint and bounded diagnostic tags.
|
|
56
56
|
//
|
|
57
57
|
// An error that carries no bounded discriminator returns `null`: the caller
|
|
58
58
|
// leaves `event.fingerprint` unset and Sentry keeps its DEFAULT grouping, so the
|
|
@@ -79,6 +79,48 @@ const KNOWN_HTTP_STATUSES = new Set([
|
|
|
79
79
|
]);
|
|
80
80
|
/** Sentry's placeholder for "keep the normal, stack-based grouping too". */
|
|
81
81
|
const DEFAULT_GROUPING = "{{ default }}";
|
|
82
|
+
const CODEX_COLLISION_NAME_RE = /^[a-z0-9_-]{1,64}$/;
|
|
83
|
+
const CODEX_COLLISION_OWNERS = new Set(["hq", "foreign", "unknown"]);
|
|
84
|
+
const CODEX_COLLISION_FIELDS = new Set(["command", "args", "env", "url", "other"]);
|
|
85
|
+
function isCodexCollisionOwner(value) {
|
|
86
|
+
return typeof value === "string" && CODEX_COLLISION_OWNERS.has(value);
|
|
87
|
+
}
|
|
88
|
+
/** Return bounded, value-free Sentry context for an instrumented Codex name collision. */
|
|
89
|
+
export function sentryMcpCollisionContextFor(err) {
|
|
90
|
+
if (err === null || typeof err !== "object")
|
|
91
|
+
return null;
|
|
92
|
+
const record = err;
|
|
93
|
+
if (record.name !== "McpNameCollisionError" || record.code !== "McpNameCollisionError") {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
const diagnostics = record.codexDiagnostics;
|
|
97
|
+
if (diagnostics === null || typeof diagnostics !== "object")
|
|
98
|
+
return null;
|
|
99
|
+
const context = diagnostics;
|
|
100
|
+
const owner = context.existingEntryOwner;
|
|
101
|
+
if (context.client !== "codex" ||
|
|
102
|
+
typeof context.serverName !== "string" ||
|
|
103
|
+
!isCodexCollisionOwner(owner) ||
|
|
104
|
+
!Array.isArray(context.diffFields) ||
|
|
105
|
+
context.diffFields.length === 0 ||
|
|
106
|
+
context.diffFields.length > CODEX_COLLISION_FIELDS.size ||
|
|
107
|
+
!context.diffFields.every((field) => typeof field === "string" && CODEX_COLLISION_FIELDS.has(field))) {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
const fields = [...new Set(context.diffFields)].sort();
|
|
111
|
+
const serverName = CODEX_COLLISION_NAME_RE.test(context.serverName)
|
|
112
|
+
? context.serverName
|
|
113
|
+
: "other";
|
|
114
|
+
return {
|
|
115
|
+
tags: {
|
|
116
|
+
mcp_client: "codex",
|
|
117
|
+
server_name: serverName,
|
|
118
|
+
existing_entry_owner: owner,
|
|
119
|
+
diff_fields: fields.join(","),
|
|
120
|
+
},
|
|
121
|
+
fingerprint: ["mcp-name-collision", "codex"],
|
|
122
|
+
};
|
|
123
|
+
}
|
|
82
124
|
/**
|
|
83
125
|
* The CLOSED set of error class names allowed to appear VERBATIM in a
|
|
84
126
|
* fingerprint. This hook runs on every capture route and accepts `unknown`, so
|
|
@@ -216,6 +258,9 @@ export function sentryFingerprintFor(err) {
|
|
|
216
258
|
const rawName = typeof record.name === "string" && record.name.length > 0 ? record.name : null;
|
|
217
259
|
if (rawName === null)
|
|
218
260
|
return null;
|
|
261
|
+
const mcpCollision = sentryMcpCollisionContextFor(record);
|
|
262
|
+
if (mcpCollision)
|
|
263
|
+
return mcpCollision.fingerprint;
|
|
219
264
|
// qmd process failures carry a PROCESS EXIT CODE in `status` (never an HTTP
|
|
220
265
|
// status) plus a subcommand in args[0]. Fingerprint them on a dedicated,
|
|
221
266
|
// fully-bounded key BEFORE the HTTP-status branch so an exit code is never read
|