@gethmy/mcp 3.4.1 → 3.6.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/README.md CHANGED
@@ -434,7 +434,7 @@ curl -X GET "https://gethmy.com/api/v1/workspaces" \
434
434
 
435
435
  ### Global Configuration
436
436
 
437
- Stored in `~/.harmony-mcp/config.json`. Browser sign-in writes OAuth tokens (and nulls `apiKey`); API-key setup writes `apiKey` instead. The server prefers a live OAuth token and refreshes it automatically:
437
+ Stored in `~/.hmy/agent/config.json`. Browser sign-in writes OAuth tokens (and nulls `apiKey`); API-key setup writes `apiKey` instead. The server prefers a live OAuth token and refreshes it automatically:
438
438
 
439
439
  ```json
440
440
  {
@@ -452,7 +452,7 @@ Stored in `~/.harmony-mcp/config.json`. Browser sign-in writes OAuth tokens (and
452
452
 
453
453
  ### Local Project Configuration
454
454
 
455
- Stored in `.harmony-mcp.json` in your project root:
455
+ Stored in `.hmy.json` in your project root:
456
456
 
457
457
  ```json
458
458
  {
@@ -498,10 +498,10 @@ Skills:
498
498
  ~/.agents/skills/hmy/SKILL.md
499
499
 
500
500
  Context:
501
- Local config: .harmony-mcp.json
501
+ Local config: .hmy.json
502
502
  Workspace: my-team-id
503
503
  Project: my-project-id
504
- Global config: ~/.harmony-mcp/config.json
504
+ Global config: ~/.hmy/agent/config.json
505
505
  Workspace: (not set)
506
506
  Project: (not set)
507
507
 
@@ -515,7 +515,7 @@ Context:
515
515
  Add this to prevent accidentally committing local config:
516
516
 
517
517
  ```
518
- .harmony-mcp.json
518
+ .hmy.json
519
519
  ```
520
520
 
521
521
  ## Architecture
package/dist/cli.js CHANGED
@@ -21,12 +21,36 @@ var __require = /* @__PURE__ */ createRequire(import.meta.url);
21
21
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
22
22
  import { homedir } from "node:os";
23
23
  import { dirname, join, parse, resolve } from "node:path";
24
+ function noteLegacyConfigDir(path) {
25
+ if (warnedLegacyConfigDir)
26
+ return;
27
+ warnedLegacyConfigDir = true;
28
+ console.error(`Harmony: reading the pre-#1082 config at ${path}. ` + `The current location is ${getConfigPath()}; ` + `run the agent daemon once to migrate, or move the file yourself.`);
29
+ }
30
+ function noteLegacyLocalPin(path) {
31
+ if (warnedLegacyLocalPin)
32
+ return;
33
+ warnedLegacyLocalPin = true;
34
+ console.error(`Harmony: this repo is pinned by ${path}, the pre-#1082 name. ` + `Rename it to ${LOCAL_CONFIG_FILENAME} — the fallback that finds it is temporary.`);
35
+ }
36
+ function noteLocalPinRename(from, to) {
37
+ console.error(`Harmony: wrote the repo pin to ${to} (the pre-#1082 ${from} is now ignored and can be deleted).`);
38
+ }
39
+ function getHmyRootDir() {
40
+ return join(homedir(), CONFIG_DIR_NAME);
41
+ }
24
42
  function getConfigDir() {
25
- return join(homedir(), ".harmony-mcp");
43
+ return join(getHmyRootDir(), CONFIG_DIR_SUBDIR);
44
+ }
45
+ function getLegacyConfigDir() {
46
+ return join(homedir(), LEGACY_CONFIG_DIR_NAME);
26
47
  }
27
48
  function getConfigPath() {
28
49
  return join(getConfigDir(), "config.json");
29
50
  }
51
+ function getLegacyConfigPath() {
52
+ return join(getLegacyConfigDir(), "config.json");
53
+ }
30
54
  function getLocalConfigPath(cwd) {
31
55
  return join(cwd || process.cwd(), LOCAL_CONFIG_FILENAME);
32
56
  }
@@ -36,9 +60,14 @@ function findLocalConfigPath(cwd) {
36
60
  const { root } = parse(dir);
37
61
  for (;; ) {
38
62
  if (dir !== home && dir !== root) {
39
- const candidate = join(dir, LOCAL_CONFIG_FILENAME);
40
- if (existsSync(candidate))
41
- return candidate;
63
+ const current = join(dir, LOCAL_CONFIG_FILENAME);
64
+ if (existsSync(current))
65
+ return current;
66
+ const legacy = join(dir, LEGACY_LOCAL_CONFIG_FILENAME);
67
+ if (existsSync(legacy)) {
68
+ noteLegacyLocalPin(legacy);
69
+ return legacy;
70
+ }
42
71
  }
43
72
  const parent = dirname(dir);
44
73
  if (parent === dir)
@@ -61,9 +90,12 @@ function emptyConfig() {
61
90
  };
62
91
  }
63
92
  function loadConfig() {
64
- const configPath = getConfigPath();
93
+ let configPath = getConfigPath();
65
94
  if (!existsSync(configPath)) {
66
- return emptyConfig();
95
+ configPath = getLegacyConfigPath();
96
+ if (!existsSync(configPath))
97
+ return emptyConfig();
98
+ noteLegacyConfigDir(configPath);
67
99
  }
68
100
  try {
69
101
  const data = readFileSync(configPath, "utf-8");
@@ -113,7 +145,11 @@ function loadLocalConfig(cwd) {
113
145
  }
114
146
  }
115
147
  function saveLocalConfig(config, cwd) {
116
- const localConfigPath = findLocalConfigPath(cwd) ?? getLocalConfigPath(cwd);
148
+ const foundPath = findLocalConfigPath(cwd);
149
+ const localConfigPath = foundPath ? join(dirname(foundPath), LOCAL_CONFIG_FILENAME) : getLocalConfigPath(cwd);
150
+ if (foundPath !== null && foundPath !== localConfigPath) {
151
+ noteLocalPinRename(foundPath, localConfigPath);
152
+ }
117
153
  const existingConfig = loadLocalConfig(cwd) || {
118
154
  workspaceId: null,
119
155
  projectId: null
@@ -125,6 +161,7 @@ function saveLocalConfig(config, cwd) {
125
161
  if (newConfig.projectId)
126
162
  cleanConfig.projectId = newConfig.projectId;
127
163
  writeFileSync(localConfigPath, JSON.stringify(cleanConfig, null, 2));
164
+ return localConfigPath;
128
165
  }
129
166
  function hasLocalConfig(cwd) {
130
167
  return findLocalConfigPath(cwd) !== null;
@@ -259,7 +296,7 @@ function getMemoryDir() {
259
296
  return config.memoryDir;
260
297
  return join(homedir(), ".harmony", "memory");
261
298
  }
262
- var DEFAULT_API_URL = "https://app.gethmy.com/api", LOCAL_CONFIG_FILENAME = ".harmony-mcp.json";
299
+ var DEFAULT_API_URL = "https://app.gethmy.com/api", LOCAL_CONFIG_FILENAME = ".hmy.json", LEGACY_LOCAL_CONFIG_FILENAME = ".harmony-mcp.json", CONFIG_DIR_NAME = ".hmy", CONFIG_DIR_SUBDIR = "agent", LEGACY_CONFIG_DIR_NAME = ".harmony-mcp", warnedLegacyConfigDir = false, warnedLegacyLocalPin = false;
263
300
  var init_config = () => {};
264
301
 
265
302
  // src/prompt-builder.ts
@@ -5693,7 +5730,7 @@ var TOOLS = {
5693
5730
  }
5694
5731
  },
5695
5732
  harmony_update_agent_progress: {
5696
- description: "Update progress on an active agent session. Use to report progress percentage, current task, blockers, or status changes. Check the reply: `session: null` with `stopped: true` means a human stopped this card's run, so reporting progress will no longer open a session here (card #770) — stop work if you were mid-run, and read `recovery` for the one legitimate way back. Otherwise a session is always returned: this call opens one if none is live.",
5733
+ description: "Update progress on an active agent session. Use to report progress percentage, current task, blockers, or status changes. Check the reply: `session: null` with `stopped: true` means a human stopped this card's run, so reporting progress will no longer open a session here (card #770) — stop work if you were mid-run, and read `recovery` for the one legitimate way back. Otherwise a session is always returned: this call opens one if none is live. `sessionStale: true` means it opened one IN PLACE OF a session the inactivity sweep had closed (card #1067): nobody stopped you and the new row keeps the old one's steering channel and driver, so carry on — but the id changed, so poll harmony_get_pending_messages with the new `session.id` from this reply, not the one you were holding. `liveSessionIdChanged: true` is the weaker cousin: the live session is simply not the one you started, so use the new id, but do NOT assume steering survived — read `instruction`.",
5697
5734
  inputSchema: {
5698
5735
  type: "object",
5699
5736
  properties: {
@@ -7654,6 +7691,32 @@ ${options}
7654
7691
  if (result.session === null) {
7655
7692
  untrack(cardId, deps.getScopeId?.());
7656
7693
  }
7694
+ const scopeId = deps.getScopeId?.();
7695
+ const ownSession = memSession && memSession.scopeId === scopeId ? memSession : undefined;
7696
+ const knownSessionId = ownSession?.agentSessionId;
7697
+ const liveSessionId = result.session?.id;
7698
+ const idChangedUnderUs = knownSessionId !== undefined && liveSessionId !== undefined && liveSessionId !== knownSessionId;
7699
+ if (idChangedUnderUs && ownSession && liveSessionId) {
7700
+ ownSession.agentSessionId = liveSessionId;
7701
+ }
7702
+ const newSessionId = liveSessionId ? ` (${liveSessionId})` : "";
7703
+ if (result.sessionStale) {
7704
+ return {
7705
+ success: true,
7706
+ midSessionLearnings: 0,
7707
+ ...result,
7708
+ instruction: "Your previous session had gone stale, so this report opened a new one in its place — nobody stopped you, and it kept the steering channel and driver of the run it replaced. Do NOT abandon the work. Poll harmony_get_pending_messages with the NEW session id" + newSessionId + ", not the one you were holding."
7709
+ };
7710
+ }
7711
+ if (idChangedUnderUs) {
7712
+ return {
7713
+ success: true,
7714
+ midSessionLearnings: 0,
7715
+ ...result,
7716
+ liveSessionIdChanged: true,
7717
+ instruction: "The live session on this card is no longer the one you started — it may have been reopened, or deliberately ended and replaced. Nobody stopped you, so carry on, but poll harmony_get_pending_messages with the session id from this reply" + newSessionId + ". If steering matters to this run, do not assume it survived: start a session deliberately with harmony_start_agent_session and steerable: true."
7718
+ };
7719
+ }
7657
7720
  return { success: true, midSessionLearnings: 0, ...result };
7658
7721
  }
7659
7722
  case "harmony_end_agent_session": {
@@ -9410,6 +9473,7 @@ function ensureDir(dirPath) {
9410
9473
  mkdirSync5(dirPath, { recursive: true, mode: 493 });
9411
9474
  }
9412
9475
  }
9476
+ var CONFIG_DIR_MARKERS = [".hmy", ".harmony-mcp"];
9413
9477
  function writeFile(filePath, content, options = {}) {
9414
9478
  const exists = existsSync8(filePath);
9415
9479
  if (exists && !options.force) {
@@ -9417,7 +9481,7 @@ function writeFile(filePath, content, options = {}) {
9417
9481
  }
9418
9482
  try {
9419
9483
  ensureDir(dirname3(filePath));
9420
- const defaultMode = filePath.includes(".harmony-mcp") ? 384 : 420;
9484
+ const defaultMode = CONFIG_DIR_MARKERS.some((marker) => filePath.includes(marker)) ? 384 : 420;
9421
9485
  const mode = options.mode ?? defaultMode;
9422
9486
  writeFileSync5(filePath, content, { mode });
9423
9487
  if (options.mode !== undefined) {
@@ -10586,14 +10650,15 @@ Specify the workspace with --workspace <id>, or select one below.`);
10586
10650
  console.log(` ${colors.dim("Skipped tool allowlist — you'll be prompted per tool, or run /permissions in Claude Code later.")}`);
10587
10651
  }
10588
10652
  }
10653
+ let writtenLocalConfigPath = null;
10589
10654
  if (selectedWorkspaceId || selectedProjectId) {
10590
10655
  const localConfig = {};
10591
10656
  if (selectedWorkspaceId)
10592
10657
  localConfig.workspaceId = selectedWorkspaceId;
10593
10658
  if (selectedProjectId)
10594
10659
  localConfig.projectId = selectedProjectId;
10595
- saveLocalConfig(localConfig, cwd);
10596
- console.log(` ${colors.success("✓")} ${colors.dim(formatPath(getLocalConfigPath(cwd), home))} ${colors.dim("(created)")}`);
10660
+ writtenLocalConfigPath = saveLocalConfig(localConfig, cwd);
10661
+ console.log(` ${colors.success("✓")} ${colors.dim(formatPath(writtenLocalConfigPath, home))} ${colors.dim("(created)")}`);
10597
10662
  if (selectedWorkspaceId || selectedProjectId) {
10598
10663
  setActiveContext({
10599
10664
  workspaceId: selectedWorkspaceId ?? null,
@@ -10623,7 +10688,7 @@ Specify the workspace with --workspace <id>, or select one below.`);
10623
10688
  console.log(` Skills: ${installMode === "global" ? "~/.agents/skills/ (global)" : ".claude/skills/ (local)"}`);
10624
10689
  }
10625
10690
  if (selectedWorkspaceId || selectedProjectId) {
10626
- console.log(` Context: ${formatPath(getLocalConfigPath(cwd), home)}`);
10691
+ console.log(` Context: ${formatPath(writtenLocalConfigPath ?? getLocalConfigPath(cwd), home)}`);
10627
10692
  }
10628
10693
  console.log("");
10629
10694
  console.log(` ${colors.bold("Usage:")}`);
package/dist/index.js CHANGED
@@ -21,12 +21,36 @@ var __require = /* @__PURE__ */ createRequire(import.meta.url);
21
21
  import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
22
22
  import { homedir } from "node:os";
23
23
  import { dirname, join as join2, parse, resolve } from "node:path";
24
+ function noteLegacyConfigDir(path) {
25
+ if (warnedLegacyConfigDir)
26
+ return;
27
+ warnedLegacyConfigDir = true;
28
+ console.error(`Harmony: reading the pre-#1082 config at ${path}. ` + `The current location is ${getConfigPath()}; ` + `run the agent daemon once to migrate, or move the file yourself.`);
29
+ }
30
+ function noteLegacyLocalPin(path) {
31
+ if (warnedLegacyLocalPin)
32
+ return;
33
+ warnedLegacyLocalPin = true;
34
+ console.error(`Harmony: this repo is pinned by ${path}, the pre-#1082 name. ` + `Rename it to ${LOCAL_CONFIG_FILENAME} — the fallback that finds it is temporary.`);
35
+ }
36
+ function noteLocalPinRename(from, to) {
37
+ console.error(`Harmony: wrote the repo pin to ${to} (the pre-#1082 ${from} is now ignored and can be deleted).`);
38
+ }
39
+ function getHmyRootDir() {
40
+ return join2(homedir(), CONFIG_DIR_NAME);
41
+ }
24
42
  function getConfigDir() {
25
- return join2(homedir(), ".harmony-mcp");
43
+ return join2(getHmyRootDir(), CONFIG_DIR_SUBDIR);
44
+ }
45
+ function getLegacyConfigDir() {
46
+ return join2(homedir(), LEGACY_CONFIG_DIR_NAME);
26
47
  }
27
48
  function getConfigPath() {
28
49
  return join2(getConfigDir(), "config.json");
29
50
  }
51
+ function getLegacyConfigPath() {
52
+ return join2(getLegacyConfigDir(), "config.json");
53
+ }
30
54
  function getLocalConfigPath(cwd) {
31
55
  return join2(cwd || process.cwd(), LOCAL_CONFIG_FILENAME);
32
56
  }
@@ -36,9 +60,14 @@ function findLocalConfigPath(cwd) {
36
60
  const { root } = parse(dir);
37
61
  for (;; ) {
38
62
  if (dir !== home && dir !== root) {
39
- const candidate = join2(dir, LOCAL_CONFIG_FILENAME);
40
- if (existsSync2(candidate))
41
- return candidate;
63
+ const current = join2(dir, LOCAL_CONFIG_FILENAME);
64
+ if (existsSync2(current))
65
+ return current;
66
+ const legacy = join2(dir, LEGACY_LOCAL_CONFIG_FILENAME);
67
+ if (existsSync2(legacy)) {
68
+ noteLegacyLocalPin(legacy);
69
+ return legacy;
70
+ }
42
71
  }
43
72
  const parent = dirname(dir);
44
73
  if (parent === dir)
@@ -61,9 +90,12 @@ function emptyConfig() {
61
90
  };
62
91
  }
63
92
  function loadConfig() {
64
- const configPath = getConfigPath();
93
+ let configPath = getConfigPath();
65
94
  if (!existsSync2(configPath)) {
66
- return emptyConfig();
95
+ configPath = getLegacyConfigPath();
96
+ if (!existsSync2(configPath))
97
+ return emptyConfig();
98
+ noteLegacyConfigDir(configPath);
67
99
  }
68
100
  try {
69
101
  const data = readFileSync2(configPath, "utf-8");
@@ -113,7 +145,11 @@ function loadLocalConfig(cwd) {
113
145
  }
114
146
  }
115
147
  function saveLocalConfig(config, cwd) {
116
- const localConfigPath = findLocalConfigPath(cwd) ?? getLocalConfigPath(cwd);
148
+ const foundPath = findLocalConfigPath(cwd);
149
+ const localConfigPath = foundPath ? join2(dirname(foundPath), LOCAL_CONFIG_FILENAME) : getLocalConfigPath(cwd);
150
+ if (foundPath !== null && foundPath !== localConfigPath) {
151
+ noteLocalPinRename(foundPath, localConfigPath);
152
+ }
117
153
  const existingConfig = loadLocalConfig(cwd) || {
118
154
  workspaceId: null,
119
155
  projectId: null
@@ -125,6 +161,7 @@ function saveLocalConfig(config, cwd) {
125
161
  if (newConfig.projectId)
126
162
  cleanConfig.projectId = newConfig.projectId;
127
163
  writeFileSync2(localConfigPath, JSON.stringify(cleanConfig, null, 2));
164
+ return localConfigPath;
128
165
  }
129
166
  function hasLocalConfig(cwd) {
130
167
  return findLocalConfigPath(cwd) !== null;
@@ -259,7 +296,7 @@ function getMemoryDir() {
259
296
  return config.memoryDir;
260
297
  return join2(homedir(), ".harmony", "memory");
261
298
  }
262
- var DEFAULT_API_URL = "https://app.gethmy.com/api", LOCAL_CONFIG_FILENAME = ".harmony-mcp.json";
299
+ var DEFAULT_API_URL = "https://app.gethmy.com/api", LOCAL_CONFIG_FILENAME = ".hmy.json", LEGACY_LOCAL_CONFIG_FILENAME = ".harmony-mcp.json", CONFIG_DIR_NAME = ".hmy", CONFIG_DIR_SUBDIR = "agent", LEGACY_CONFIG_DIR_NAME = ".harmony-mcp", warnedLegacyConfigDir = false, warnedLegacyLocalPin = false;
263
300
  var init_config = () => {};
264
301
 
265
302
  // src/prompt-builder.ts
@@ -5476,7 +5513,7 @@ var TOOLS = {
5476
5513
  }
5477
5514
  },
5478
5515
  harmony_update_agent_progress: {
5479
- description: "Update progress on an active agent session. Use to report progress percentage, current task, blockers, or status changes. Check the reply: `session: null` with `stopped: true` means a human stopped this card's run, so reporting progress will no longer open a session here (card #770) — stop work if you were mid-run, and read `recovery` for the one legitimate way back. Otherwise a session is always returned: this call opens one if none is live.",
5516
+ description: "Update progress on an active agent session. Use to report progress percentage, current task, blockers, or status changes. Check the reply: `session: null` with `stopped: true` means a human stopped this card's run, so reporting progress will no longer open a session here (card #770) — stop work if you were mid-run, and read `recovery` for the one legitimate way back. Otherwise a session is always returned: this call opens one if none is live. `sessionStale: true` means it opened one IN PLACE OF a session the inactivity sweep had closed (card #1067): nobody stopped you and the new row keeps the old one's steering channel and driver, so carry on — but the id changed, so poll harmony_get_pending_messages with the new `session.id` from this reply, not the one you were holding. `liveSessionIdChanged: true` is the weaker cousin: the live session is simply not the one you started, so use the new id, but do NOT assume steering survived — read `instruction`.",
5480
5517
  inputSchema: {
5481
5518
  type: "object",
5482
5519
  properties: {
@@ -7437,6 +7474,32 @@ ${options}
7437
7474
  if (result.session === null) {
7438
7475
  untrack(cardId, deps.getScopeId?.());
7439
7476
  }
7477
+ const scopeId = deps.getScopeId?.();
7478
+ const ownSession = memSession && memSession.scopeId === scopeId ? memSession : undefined;
7479
+ const knownSessionId = ownSession?.agentSessionId;
7480
+ const liveSessionId = result.session?.id;
7481
+ const idChangedUnderUs = knownSessionId !== undefined && liveSessionId !== undefined && liveSessionId !== knownSessionId;
7482
+ if (idChangedUnderUs && ownSession && liveSessionId) {
7483
+ ownSession.agentSessionId = liveSessionId;
7484
+ }
7485
+ const newSessionId = liveSessionId ? ` (${liveSessionId})` : "";
7486
+ if (result.sessionStale) {
7487
+ return {
7488
+ success: true,
7489
+ midSessionLearnings: 0,
7490
+ ...result,
7491
+ instruction: "Your previous session had gone stale, so this report opened a new one in its place — nobody stopped you, and it kept the steering channel and driver of the run it replaced. Do NOT abandon the work. Poll harmony_get_pending_messages with the NEW session id" + newSessionId + ", not the one you were holding."
7492
+ };
7493
+ }
7494
+ if (idChangedUnderUs) {
7495
+ return {
7496
+ success: true,
7497
+ midSessionLearnings: 0,
7498
+ ...result,
7499
+ liveSessionIdChanged: true,
7500
+ instruction: "The live session on this card is no longer the one you started — it may have been reopened, or deliberately ended and replaced. Nobody stopped you, so carry on, but poll harmony_get_pending_messages with the session id from this reply" + newSessionId + ". If steering matters to this run, do not assume it survived: start a session deliberately with harmony_start_agent_session and steerable: true."
7501
+ };
7502
+ }
7440
7503
  return { success: true, midSessionLearnings: 0, ...result };
7441
7504
  }
7442
7505
  case "harmony_end_agent_session": {
@@ -18,12 +18,40 @@ var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
18
18
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
19
19
  import { homedir } from "node:os";
20
20
  import { dirname, join, parse, resolve } from "node:path";
21
+ function resetLegacyNoticesForTest() {
22
+ warnedLegacyConfigDir = false;
23
+ warnedLegacyLocalPin = false;
24
+ }
25
+ function noteLegacyConfigDir(path) {
26
+ if (warnedLegacyConfigDir)
27
+ return;
28
+ warnedLegacyConfigDir = true;
29
+ console.error(`Harmony: reading the pre-#1082 config at ${path}. ` + `The current location is ${getConfigPath()}; ` + `run the agent daemon once to migrate, or move the file yourself.`);
30
+ }
31
+ function noteLegacyLocalPin(path) {
32
+ if (warnedLegacyLocalPin)
33
+ return;
34
+ warnedLegacyLocalPin = true;
35
+ console.error(`Harmony: this repo is pinned by ${path}, the pre-#1082 name. ` + `Rename it to ${LOCAL_CONFIG_FILENAME} — the fallback that finds it is temporary.`);
36
+ }
37
+ function noteLocalPinRename(from, to) {
38
+ console.error(`Harmony: wrote the repo pin to ${to} (the pre-#1082 ${from} is now ignored and can be deleted).`);
39
+ }
40
+ function getHmyRootDir() {
41
+ return join(homedir(), CONFIG_DIR_NAME);
42
+ }
21
43
  function getConfigDir() {
22
- return join(homedir(), ".harmony-mcp");
44
+ return join(getHmyRootDir(), CONFIG_DIR_SUBDIR);
45
+ }
46
+ function getLegacyConfigDir() {
47
+ return join(homedir(), LEGACY_CONFIG_DIR_NAME);
23
48
  }
24
49
  function getConfigPath() {
25
50
  return join(getConfigDir(), "config.json");
26
51
  }
52
+ function getLegacyConfigPath() {
53
+ return join(getLegacyConfigDir(), "config.json");
54
+ }
27
55
  function getLocalConfigPath(cwd) {
28
56
  return join(cwd || process.cwd(), LOCAL_CONFIG_FILENAME);
29
57
  }
@@ -33,9 +61,14 @@ function findLocalConfigPath(cwd) {
33
61
  const { root } = parse(dir);
34
62
  for (;; ) {
35
63
  if (dir !== home && dir !== root) {
36
- const candidate = join(dir, LOCAL_CONFIG_FILENAME);
37
- if (existsSync(candidate))
38
- return candidate;
64
+ const current = join(dir, LOCAL_CONFIG_FILENAME);
65
+ if (existsSync(current))
66
+ return current;
67
+ const legacy = join(dir, LEGACY_LOCAL_CONFIG_FILENAME);
68
+ if (existsSync(legacy)) {
69
+ noteLegacyLocalPin(legacy);
70
+ return legacy;
71
+ }
39
72
  }
40
73
  const parent = dirname(dir);
41
74
  if (parent === dir)
@@ -58,9 +91,12 @@ function emptyConfig() {
58
91
  };
59
92
  }
60
93
  function loadConfig() {
61
- const configPath = getConfigPath();
94
+ let configPath = getConfigPath();
62
95
  if (!existsSync(configPath)) {
63
- return emptyConfig();
96
+ configPath = getLegacyConfigPath();
97
+ if (!existsSync(configPath))
98
+ return emptyConfig();
99
+ noteLegacyConfigDir(configPath);
64
100
  }
65
101
  try {
66
102
  const data = readFileSync(configPath, "utf-8");
@@ -110,7 +146,11 @@ function loadLocalConfig(cwd) {
110
146
  }
111
147
  }
112
148
  function saveLocalConfig(config, cwd) {
113
- const localConfigPath = findLocalConfigPath(cwd) ?? getLocalConfigPath(cwd);
149
+ const foundPath = findLocalConfigPath(cwd);
150
+ const localConfigPath = foundPath ? join(dirname(foundPath), LOCAL_CONFIG_FILENAME) : getLocalConfigPath(cwd);
151
+ if (foundPath !== null && foundPath !== localConfigPath) {
152
+ noteLocalPinRename(foundPath, localConfigPath);
153
+ }
114
154
  const existingConfig = loadLocalConfig(cwd) || {
115
155
  workspaceId: null,
116
156
  projectId: null
@@ -122,6 +162,7 @@ function saveLocalConfig(config, cwd) {
122
162
  if (newConfig.projectId)
123
163
  cleanConfig.projectId = newConfig.projectId;
124
164
  writeFileSync(localConfigPath, JSON.stringify(cleanConfig, null, 2));
165
+ return localConfigPath;
125
166
  }
126
167
  function hasLocalConfig(cwd) {
127
168
  return findLocalConfigPath(cwd) !== null;
@@ -259,7 +300,7 @@ function getMemoryDir() {
259
300
  return config.memoryDir;
260
301
  return join(homedir(), ".harmony", "memory");
261
302
  }
262
- var DEFAULT_API_URL = "https://app.gethmy.com/api", LOCAL_CONFIG_FILENAME = ".harmony-mcp.json";
303
+ var DEFAULT_API_URL = "https://app.gethmy.com/api", LOCAL_CONFIG_FILENAME = ".hmy.json", LEGACY_LOCAL_CONFIG_FILENAME = ".harmony-mcp.json", CONFIG_DIR_NAME = ".hmy", CONFIG_DIR_SUBDIR = "agent", LEGACY_CONFIG_DIR_NAME = ".harmony-mcp", warnedLegacyConfigDir = false, warnedLegacyLocalPin = false;
263
304
  var init_config = () => {};
264
305
 
265
306
  // src/oauth-login.ts
@@ -18,12 +18,40 @@ var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
18
18
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
19
19
  import { homedir } from "node:os";
20
20
  import { dirname, join, parse, resolve } from "node:path";
21
+ function resetLegacyNoticesForTest() {
22
+ warnedLegacyConfigDir = false;
23
+ warnedLegacyLocalPin = false;
24
+ }
25
+ function noteLegacyConfigDir(path) {
26
+ if (warnedLegacyConfigDir)
27
+ return;
28
+ warnedLegacyConfigDir = true;
29
+ console.error(`Harmony: reading the pre-#1082 config at ${path}. ` + `The current location is ${getConfigPath()}; ` + `run the agent daemon once to migrate, or move the file yourself.`);
30
+ }
31
+ function noteLegacyLocalPin(path) {
32
+ if (warnedLegacyLocalPin)
33
+ return;
34
+ warnedLegacyLocalPin = true;
35
+ console.error(`Harmony: this repo is pinned by ${path}, the pre-#1082 name. ` + `Rename it to ${LOCAL_CONFIG_FILENAME} — the fallback that finds it is temporary.`);
36
+ }
37
+ function noteLocalPinRename(from, to) {
38
+ console.error(`Harmony: wrote the repo pin to ${to} (the pre-#1082 ${from} is now ignored and can be deleted).`);
39
+ }
40
+ function getHmyRootDir() {
41
+ return join(homedir(), CONFIG_DIR_NAME);
42
+ }
21
43
  function getConfigDir() {
22
- return join(homedir(), ".harmony-mcp");
44
+ return join(getHmyRootDir(), CONFIG_DIR_SUBDIR);
45
+ }
46
+ function getLegacyConfigDir() {
47
+ return join(homedir(), LEGACY_CONFIG_DIR_NAME);
23
48
  }
24
49
  function getConfigPath() {
25
50
  return join(getConfigDir(), "config.json");
26
51
  }
52
+ function getLegacyConfigPath() {
53
+ return join(getLegacyConfigDir(), "config.json");
54
+ }
27
55
  function getLocalConfigPath(cwd) {
28
56
  return join(cwd || process.cwd(), LOCAL_CONFIG_FILENAME);
29
57
  }
@@ -33,9 +61,14 @@ function findLocalConfigPath(cwd) {
33
61
  const { root } = parse(dir);
34
62
  for (;; ) {
35
63
  if (dir !== home && dir !== root) {
36
- const candidate = join(dir, LOCAL_CONFIG_FILENAME);
37
- if (existsSync(candidate))
38
- return candidate;
64
+ const current = join(dir, LOCAL_CONFIG_FILENAME);
65
+ if (existsSync(current))
66
+ return current;
67
+ const legacy = join(dir, LEGACY_LOCAL_CONFIG_FILENAME);
68
+ if (existsSync(legacy)) {
69
+ noteLegacyLocalPin(legacy);
70
+ return legacy;
71
+ }
39
72
  }
40
73
  const parent = dirname(dir);
41
74
  if (parent === dir)
@@ -58,9 +91,12 @@ function emptyConfig() {
58
91
  };
59
92
  }
60
93
  function loadConfig() {
61
- const configPath = getConfigPath();
94
+ let configPath = getConfigPath();
62
95
  if (!existsSync(configPath)) {
63
- return emptyConfig();
96
+ configPath = getLegacyConfigPath();
97
+ if (!existsSync(configPath))
98
+ return emptyConfig();
99
+ noteLegacyConfigDir(configPath);
64
100
  }
65
101
  try {
66
102
  const data = readFileSync(configPath, "utf-8");
@@ -110,7 +146,11 @@ function loadLocalConfig(cwd) {
110
146
  }
111
147
  }
112
148
  function saveLocalConfig(config, cwd) {
113
- const localConfigPath = findLocalConfigPath(cwd) ?? getLocalConfigPath(cwd);
149
+ const foundPath = findLocalConfigPath(cwd);
150
+ const localConfigPath = foundPath ? join(dirname(foundPath), LOCAL_CONFIG_FILENAME) : getLocalConfigPath(cwd);
151
+ if (foundPath !== null && foundPath !== localConfigPath) {
152
+ noteLocalPinRename(foundPath, localConfigPath);
153
+ }
114
154
  const existingConfig = loadLocalConfig(cwd) || {
115
155
  workspaceId: null,
116
156
  projectId: null
@@ -122,6 +162,7 @@ function saveLocalConfig(config, cwd) {
122
162
  if (newConfig.projectId)
123
163
  cleanConfig.projectId = newConfig.projectId;
124
164
  writeFileSync(localConfigPath, JSON.stringify(cleanConfig, null, 2));
165
+ return localConfigPath;
125
166
  }
126
167
  function hasLocalConfig(cwd) {
127
168
  return findLocalConfigPath(cwd) !== null;
@@ -259,7 +300,7 @@ function getMemoryDir() {
259
300
  return config.memoryDir;
260
301
  return join(homedir(), ".harmony", "memory");
261
302
  }
262
- var DEFAULT_API_URL = "https://app.gethmy.com/api", LOCAL_CONFIG_FILENAME = ".harmony-mcp.json";
303
+ var DEFAULT_API_URL = "https://app.gethmy.com/api", LOCAL_CONFIG_FILENAME = ".hmy.json", LEGACY_LOCAL_CONFIG_FILENAME = ".harmony-mcp.json", CONFIG_DIR_NAME = ".hmy", CONFIG_DIR_SUBDIR = "agent", LEGACY_CONFIG_DIR_NAME = ".harmony-mcp", warnedLegacyConfigDir = false, warnedLegacyLocalPin = false;
263
304
  var init_config = () => {};
264
305
  init_config();
265
306
 
@@ -269,6 +310,8 @@ export {
269
310
  setActiveContext,
270
311
  saveLocalConfig,
271
312
  saveConfig,
313
+ resetLegacyNoticesForTest,
314
+ noteLegacyLocalPin,
272
315
  loadLocalConfig,
273
316
  loadConfig,
274
317
  isConfigured,
@@ -277,6 +320,9 @@ export {
277
320
  getUserEmail,
278
321
  getMemoryDir,
279
322
  getLocalConfigPath,
323
+ getLegacyConfigPath,
324
+ getLegacyConfigDir,
325
+ getHmyRootDir,
280
326
  getConfigPath,
281
327
  getConfigDir,
282
328
  getApiUrl,
@@ -287,5 +333,6 @@ export {
287
333
  getActiveContext,
288
334
  findLocalConfigPath,
289
335
  describeActiveContext,
290
- areSkillsInstalled
336
+ areSkillsInstalled,
337
+ LEGACY_LOCAL_CONFIG_FILENAME
291
338
  };
@@ -18,12 +18,40 @@ var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
18
18
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
19
19
  import { homedir } from "node:os";
20
20
  import { dirname, join, parse, resolve } from "node:path";
21
+ function resetLegacyNoticesForTest() {
22
+ warnedLegacyConfigDir = false;
23
+ warnedLegacyLocalPin = false;
24
+ }
25
+ function noteLegacyConfigDir(path) {
26
+ if (warnedLegacyConfigDir)
27
+ return;
28
+ warnedLegacyConfigDir = true;
29
+ console.error(`Harmony: reading the pre-#1082 config at ${path}. ` + `The current location is ${getConfigPath()}; ` + `run the agent daemon once to migrate, or move the file yourself.`);
30
+ }
31
+ function noteLegacyLocalPin(path) {
32
+ if (warnedLegacyLocalPin)
33
+ return;
34
+ warnedLegacyLocalPin = true;
35
+ console.error(`Harmony: this repo is pinned by ${path}, the pre-#1082 name. ` + `Rename it to ${LOCAL_CONFIG_FILENAME} — the fallback that finds it is temporary.`);
36
+ }
37
+ function noteLocalPinRename(from, to) {
38
+ console.error(`Harmony: wrote the repo pin to ${to} (the pre-#1082 ${from} is now ignored and can be deleted).`);
39
+ }
40
+ function getHmyRootDir() {
41
+ return join(homedir(), CONFIG_DIR_NAME);
42
+ }
21
43
  function getConfigDir() {
22
- return join(homedir(), ".harmony-mcp");
44
+ return join(getHmyRootDir(), CONFIG_DIR_SUBDIR);
45
+ }
46
+ function getLegacyConfigDir() {
47
+ return join(homedir(), LEGACY_CONFIG_DIR_NAME);
23
48
  }
24
49
  function getConfigPath() {
25
50
  return join(getConfigDir(), "config.json");
26
51
  }
52
+ function getLegacyConfigPath() {
53
+ return join(getLegacyConfigDir(), "config.json");
54
+ }
27
55
  function getLocalConfigPath(cwd) {
28
56
  return join(cwd || process.cwd(), LOCAL_CONFIG_FILENAME);
29
57
  }
@@ -33,9 +61,14 @@ function findLocalConfigPath(cwd) {
33
61
  const { root } = parse(dir);
34
62
  for (;; ) {
35
63
  if (dir !== home && dir !== root) {
36
- const candidate = join(dir, LOCAL_CONFIG_FILENAME);
37
- if (existsSync(candidate))
38
- return candidate;
64
+ const current = join(dir, LOCAL_CONFIG_FILENAME);
65
+ if (existsSync(current))
66
+ return current;
67
+ const legacy = join(dir, LEGACY_LOCAL_CONFIG_FILENAME);
68
+ if (existsSync(legacy)) {
69
+ noteLegacyLocalPin(legacy);
70
+ return legacy;
71
+ }
39
72
  }
40
73
  const parent = dirname(dir);
41
74
  if (parent === dir)
@@ -58,9 +91,12 @@ function emptyConfig() {
58
91
  };
59
92
  }
60
93
  function loadConfig() {
61
- const configPath = getConfigPath();
94
+ let configPath = getConfigPath();
62
95
  if (!existsSync(configPath)) {
63
- return emptyConfig();
96
+ configPath = getLegacyConfigPath();
97
+ if (!existsSync(configPath))
98
+ return emptyConfig();
99
+ noteLegacyConfigDir(configPath);
64
100
  }
65
101
  try {
66
102
  const data = readFileSync(configPath, "utf-8");
@@ -110,7 +146,11 @@ function loadLocalConfig(cwd) {
110
146
  }
111
147
  }
112
148
  function saveLocalConfig(config, cwd) {
113
- const localConfigPath = findLocalConfigPath(cwd) ?? getLocalConfigPath(cwd);
149
+ const foundPath = findLocalConfigPath(cwd);
150
+ const localConfigPath = foundPath ? join(dirname(foundPath), LOCAL_CONFIG_FILENAME) : getLocalConfigPath(cwd);
151
+ if (foundPath !== null && foundPath !== localConfigPath) {
152
+ noteLocalPinRename(foundPath, localConfigPath);
153
+ }
114
154
  const existingConfig = loadLocalConfig(cwd) || {
115
155
  workspaceId: null,
116
156
  projectId: null
@@ -122,6 +162,7 @@ function saveLocalConfig(config, cwd) {
122
162
  if (newConfig.projectId)
123
163
  cleanConfig.projectId = newConfig.projectId;
124
164
  writeFileSync(localConfigPath, JSON.stringify(cleanConfig, null, 2));
165
+ return localConfigPath;
125
166
  }
126
167
  function hasLocalConfig(cwd) {
127
168
  return findLocalConfigPath(cwd) !== null;
@@ -259,7 +300,7 @@ function getMemoryDir() {
259
300
  return config.memoryDir;
260
301
  return join(homedir(), ".harmony", "memory");
261
302
  }
262
- var DEFAULT_API_URL = "https://app.gethmy.com/api", LOCAL_CONFIG_FILENAME = ".harmony-mcp.json";
303
+ var DEFAULT_API_URL = "https://app.gethmy.com/api", LOCAL_CONFIG_FILENAME = ".hmy.json", LEGACY_LOCAL_CONFIG_FILENAME = ".harmony-mcp.json", CONFIG_DIR_NAME = ".hmy", CONFIG_DIR_SUBDIR = "agent", LEGACY_CONFIG_DIR_NAME = ".harmony-mcp", warnedLegacyConfigDir = false, warnedLegacyLocalPin = false;
263
304
  var init_config = () => {};
264
305
 
265
306
  // src/oauth-login.ts
@@ -377,6 +377,7 @@ var SENSITIVE_SEGMENTS = [
377
377
  ".gemini",
378
378
  ".docker",
379
379
  ".kube",
380
+ ".hmy",
380
381
  ".harmony-mcp",
381
382
  ".password-store",
382
383
  ".claude",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gethmy/mcp",
3
- "version": "3.4.1",
3
+ "version": "3.6.0",
4
4
  "description": "MCP server for Harmony, the shared surface for human–agent teams — agents claim cards, report progress, and move work on your board.",
5
5
  "publishConfig": {
6
6
  "access": "public"
package/src/api-client.ts CHANGED
@@ -1335,6 +1335,23 @@ export class HarmonyApiClient {
1335
1335
  stopReason?: "human_stopped";
1336
1336
  /** How to legitimately work this card again (an explicit start). */
1337
1337
  recovery?: string;
1338
+ /**
1339
+ * This write REPLACED a session the silence sweep had closed (#1067): the
1340
+ * returned session is a new row under a new id, carrying forward the
1341
+ * `steerable`/`driver` of the one it replaced. Not a stop — the run
1342
+ * continues — but every later poll must use the NEW `session.id`.
1343
+ *
1344
+ * Same word `getPendingMessages` answers with, deliberately: one flag name
1345
+ * for one fact across both calls. Absent on an ordinary update, on a
1346
+ * deliberate start, and from a pre-#1067 server.
1347
+ */
1348
+ sessionStale?: boolean;
1349
+ /**
1350
+ * The swept session that was replaced — never the new one's id. Present
1351
+ * exactly when `sessionStale` is, so it is also the discriminator between
1352
+ * the server's own verdict and the MCP tool's weaker `liveSessionIdChanged`.
1353
+ */
1354
+ replacedSessionId?: string;
1338
1355
  }> {
1339
1356
  return this.request("POST", `/cards/${cardId}/agent-context`, data);
1340
1357
  }
package/src/config.ts CHANGED
@@ -22,7 +22,7 @@ export interface HarmonyConfig {
22
22
  }
23
23
 
24
24
  /**
25
- * Local project-level config (stored in .harmony-mcp.json in project root).
25
+ * Local project-level config (stored in .hmy.json in project root).
26
26
  * Only contains context IDs - API key stays global for security.
27
27
  */
28
28
  export interface LocalConfig {
@@ -31,22 +31,131 @@ export interface LocalConfig {
31
31
  }
32
32
 
33
33
  const DEFAULT_API_URL = "https://app.gethmy.com/api";
34
- const LOCAL_CONFIG_FILENAME = ".harmony-mcp.json";
35
34
 
35
+ /**
36
+ * The config surface was renamed to `hmy` in card #1082 — `.harmony-mcp.json`
37
+ * became `.hmy.json`, and the home directory `~/.harmony-mcp/` became
38
+ * `~/.hmy/agent/`.
39
+ *
40
+ * `~/.hmy` already existed and belongs to the skill auto-update layer
41
+ * (`config.yaml`, `VERSION`, `bin/`, see `hmy-config.ts`), so the daemon's own
42
+ * state took the `agent/` subdirectory rather than landing a second file named
43
+ * `config.*` — in a different format, with a different owner — beside it.
44
+ *
45
+ * **Writes always go to the new name; reads fall back to the old one.**
46
+ * `@gethmy/mcp` and `@gethmy/agent` are published and the daemon runs from a
47
+ * frozen npx cache, so a hard cut would leave a running daemon unable to find
48
+ * its config until a coordinated release. `migrateConfigDir` (harmony-agent)
49
+ * moves the data once, at daemon startup, which makes the fallback moot in
50
+ * practice — it stays only for a client that never runs the daemon.
51
+ *
52
+ * The legacy spellings are exported because two things still need them after
53
+ * the fallback is removed: the migration, and the harness read-denylists. The
54
+ * old directory keeps holding an API key and 3430 run logs until an operator
55
+ * deletes it, so denying only the new name would OPEN it. See
56
+ * `credentialDirectories()` in harmony-harness and `HARNESS_CREDENTIAL_LEAVES`
57
+ * in `run-redaction.ts`; both name old and new.
58
+ */
59
+ const LOCAL_CONFIG_FILENAME = ".hmy.json";
60
+ export const LEGACY_LOCAL_CONFIG_FILENAME = ".harmony-mcp.json";
61
+ const CONFIG_DIR_NAME = ".hmy";
62
+ const CONFIG_DIR_SUBDIR = "agent";
63
+ const LEGACY_CONFIG_DIR_NAME = ".harmony-mcp";
64
+
65
+ /**
66
+ * Fallbacks announce themselves ONCE per process (#1082).
67
+ *
68
+ * A silent fallback is the failure mode this whole compatibility window is
69
+ * exposed to: an operator whose migration failed, or whose daemon runs from a
70
+ * frozen npx cache, gets no signal that they still depend on a path scheduled
71
+ * for removal — and then the follow-up card that deletes the fallback breaks
72
+ * them with nothing in any log to connect the two.
73
+ *
74
+ * Latched per process rather than per call because these sit on hot paths —
75
+ * `loadConfig` runs on every tool dispatch — and a line repeated hundreds of
76
+ * times is read as noise and filtered out, which is the same as silence.
77
+ *
78
+ * `console.error`, not `console.log`: on stdio transport stdout carries the
79
+ * JSON-RPC protocol, so anything printed there corrupts the session. Same
80
+ * convention as `skills.ts`.
81
+ */
82
+ let warnedLegacyConfigDir = false;
83
+ let warnedLegacyLocalPin = false;
84
+
85
+ /**
86
+ * Clear both latches. Tests only.
87
+ *
88
+ * A per-process latch is not observable twice, so without this the "said once"
89
+ * half of the contract is untestable — and an untested notice is how the
90
+ * fallback goes silent again without anyone noticing. Exported deliberately
91
+ * rather than left to a test reaching into module state.
92
+ */
93
+ export function resetLegacyNoticesForTest(): void {
94
+ warnedLegacyConfigDir = false;
95
+ warnedLegacyLocalPin = false;
96
+ }
97
+
98
+ function noteLegacyConfigDir(path: string): void {
99
+ if (warnedLegacyConfigDir) return;
100
+ warnedLegacyConfigDir = true;
101
+ console.error(
102
+ `Harmony: reading the pre-#1082 config at ${path}. ` +
103
+ `The current location is ${getConfigPath()}; ` +
104
+ `run the agent daemon once to migrate, or move the file yourself.`,
105
+ );
106
+ }
107
+
108
+ /** Said once when a repo pin is read under the old name. */
109
+ export function noteLegacyLocalPin(path: string): void {
110
+ if (warnedLegacyLocalPin) return;
111
+ warnedLegacyLocalPin = true;
112
+ console.error(
113
+ `Harmony: this repo is pinned by ${path}, the pre-#1082 name. ` +
114
+ `Rename it to ${LOCAL_CONFIG_FILENAME} — the fallback that finds it is temporary.`,
115
+ );
116
+ }
117
+
118
+ /** Said once when a write moves the pin to the current name. */
119
+ function noteLocalPinRename(from: string, to: string): void {
120
+ console.error(
121
+ `Harmony: wrote the repo pin to ${to} (the pre-#1082 ${from} is now ignored and can be deleted).`,
122
+ );
123
+ }
124
+
125
+ /**
126
+ * `~/.hmy` — the root the whole `hmy` surface shares. Named separately from
127
+ * `getConfigDir()` because the denylists want the WHOLE tree, not just the
128
+ * daemon's corner of it: `config.yaml` is not a credential, but a future
129
+ * sibling might be, and a directory has no spellings to enumerate.
130
+ */
131
+ export function getHmyRootDir(): string {
132
+ return join(homedir(), CONFIG_DIR_NAME);
133
+ }
134
+
135
+ /** `~/.hmy/agent` — where every writer writes. Never falls back. */
36
136
  export function getConfigDir(): string {
37
- return join(homedir(), ".harmony-mcp");
137
+ return join(getHmyRootDir(), CONFIG_DIR_SUBDIR);
138
+ }
139
+
140
+ /** `~/.harmony-mcp` — the pre-#1082 location. Read-only, and denied forever. */
141
+ export function getLegacyConfigDir(): string {
142
+ return join(homedir(), LEGACY_CONFIG_DIR_NAME);
38
143
  }
39
144
 
40
145
  export function getConfigPath(): string {
41
146
  return join(getConfigDir(), "config.json");
42
147
  }
43
148
 
149
+ export function getLegacyConfigPath(): string {
150
+ return join(getLegacyConfigDir(), "config.json");
151
+ }
152
+
44
153
  export function getLocalConfigPath(cwd?: string): string {
45
154
  return join(cwd || process.cwd(), LOCAL_CONFIG_FILENAME);
46
155
  }
47
156
 
48
157
  /**
49
- * Find the nearest `.harmony-mcp.json` at or above `cwd` (card #893).
158
+ * Find the nearest `.hmy.json` at or above `cwd` (card #893).
50
159
  *
51
160
  * The old behaviour looked in the exact cwd and nowhere else, so a server
52
161
  * started from a package directory or a git worktree never saw the repo's pin
@@ -56,9 +165,15 @@ export function getLocalConfigPath(cwd?: string): string {
56
165
  * Two directories are deliberately skipped, however deep the walk goes:
57
166
  *
58
167
  * - **the home directory** — a stray file there would capture every session in
59
- * every repo, and `~/.harmony-mcp/config.json` is already the global pin;
168
+ * every repo, and `~/.hmy/agent/config.json` is already the global pin;
60
169
  * - **the filesystem root** — same reasoning, machine-wide.
61
170
  *
171
+ * **Both names are checked in the SAME directory before walking up** (#1082),
172
+ * which is the only ordering that preserves #893. Checking every ancestor for
173
+ * `.hmy.json` first and only then re-walking for `.harmony-mcp.json` would let
174
+ * a parent repo's new-name pin beat the current repo's own legacy pin — the
175
+ * nearest file must win whichever name it carries.
176
+ *
62
177
  * Returns `null` when no ancestor carries one.
63
178
  */
64
179
  export function findLocalConfigPath(cwd?: string): string | null {
@@ -68,8 +183,13 @@ export function findLocalConfigPath(cwd?: string): string | null {
68
183
 
69
184
  for (;;) {
70
185
  if (dir !== home && dir !== root) {
71
- const candidate = join(dir, LOCAL_CONFIG_FILENAME);
72
- if (existsSync(candidate)) return candidate;
186
+ const current = join(dir, LOCAL_CONFIG_FILENAME);
187
+ if (existsSync(current)) return current;
188
+ const legacy = join(dir, LEGACY_LOCAL_CONFIG_FILENAME);
189
+ if (existsSync(legacy)) {
190
+ noteLegacyLocalPin(legacy);
191
+ return legacy;
192
+ }
73
193
  }
74
194
  const parent = dirname(dir);
75
195
  if (parent === dir) return null;
@@ -93,10 +213,14 @@ function emptyConfig(): HarmonyConfig {
93
213
  }
94
214
 
95
215
  export function loadConfig(): HarmonyConfig {
96
- const configPath = getConfigPath();
97
-
216
+ // Read the new location, fall back to the pre-#1082 one. The fallback is
217
+ // read-only on purpose: `saveConfig` always writes the new path, so the first
218
+ // write after an upgrade lands there and the old file stops being consulted.
219
+ let configPath = getConfigPath();
98
220
  if (!existsSync(configPath)) {
99
- return emptyConfig();
221
+ configPath = getLegacyConfigPath();
222
+ if (!existsSync(configPath)) return emptyConfig();
223
+ noteLegacyConfigDir(configPath);
100
224
  }
101
225
 
102
226
  try {
@@ -157,14 +281,33 @@ export function loadLocalConfig(cwd?: string): LocalConfig | null {
157
281
  }
158
282
  }
159
283
 
284
+ /** Returns the path actually written, which is NOT always `<cwd>/.hmy.json`. */
160
285
  export function saveLocalConfig(
161
286
  config: Partial<LocalConfig>,
162
287
  cwd?: string,
163
- ): void {
164
- // Write back to the file we READ, not to the cwd: a `set_project_context`
165
- // issued from a package directory must update the repo's pin rather than
166
- // strand a second config file the repo root never looks at (card #893).
167
- const localConfigPath = findLocalConfigPath(cwd) ?? getLocalConfigPath(cwd);
288
+ ): string {
289
+ // Write back to the DIRECTORY we read from, under the CURRENT name (#893,
290
+ // #1082). #893's rule is about the directory: a `set_project_context` issued
291
+ // from a package subdirectory must update the repo's pin rather than strand a
292
+ // second config file the repo root never looks at. The filename was never the
293
+ // point, and writing back to `.harmony-mcp.json` because that is what the read
294
+ // found would make this the one writer still creating the old name — the
295
+ // thing #1082 exists to stop, and a live trap for the card that removes the
296
+ // read fallback, since a pin left under the old name goes invisible and the
297
+ // session falls through to the global context. That silent wrong-workspace
298
+ // failure is exactly what #893 was filed to remove.
299
+ //
300
+ // The legacy file is left on disk rather than deleted, matching
301
+ // `migrateConfigDir`'s doctrine: this code removes nothing an operator wrote.
302
+ // It is inert from the next read on — `findLocalConfigPath` prefers
303
+ // `.hmy.json` in the same directory — and `noteLocalPinRename` says so once.
304
+ const foundPath = findLocalConfigPath(cwd);
305
+ const localConfigPath = foundPath
306
+ ? join(dirname(foundPath), LOCAL_CONFIG_FILENAME)
307
+ : getLocalConfigPath(cwd);
308
+ if (foundPath !== null && foundPath !== localConfigPath) {
309
+ noteLocalPinRename(foundPath, localConfigPath);
310
+ }
168
311
 
169
312
  const existingConfig = loadLocalConfig(cwd) || {
170
313
  workspaceId: null,
@@ -178,6 +321,7 @@ export function saveLocalConfig(
178
321
  if (newConfig.projectId) cleanConfig.projectId = newConfig.projectId;
179
322
 
180
323
  writeFileSync(localConfigPath, JSON.stringify(cleanConfig, null, 2));
324
+ return localConfigPath;
181
325
  }
182
326
 
183
327
  export function hasLocalConfig(cwd?: string): boolean {
@@ -222,10 +366,10 @@ export function setUserEmail(email: string | null): void {
222
366
  }
223
367
 
224
368
  export interface SetContextOptions {
225
- /** Force a write to the repo-local `.harmony-mcp.json` (creating it). */
369
+ /** Force a write to the repo-local `.hmy.json` (creating it). */
226
370
  local?: boolean;
227
371
  /**
228
- * Force a write to the global `~/.harmony-mcp/config.json`, even when a local
372
+ * Force a write to the global `~/.hmy/agent/config.json`, even when a local
229
373
  * file exists. Setup uses this to mirror the chosen context into the global
230
374
  * default; without it the mirror would land back in the local file it just
231
375
  * wrote, leaving every server started from another directory with no context.
@@ -318,7 +462,7 @@ export function setActiveWorkspace(
318
462
  /**
319
463
  * The active pair, read from ONE source (card #893).
320
464
  *
321
- * A local `.harmony-mcp.json` wins **as a whole file**, not field by field. The
465
+ * A local `.hmy.json` wins **as a whole file**, not field by field. The
322
466
  * two ids used to fall back to the global config independently, which quietly
323
467
  * re-created the very mismatch this card removes: `saveLocalConfig` omits null
324
468
  * values, so writing `projectId: null` locally did not clear the project — it
@@ -12,7 +12,7 @@
12
12
  * `hooks` is not merely missing from that list — it is the key that BROKE the
13
13
  * previous denylist and forced the inversion. A hook block in a run's own
14
14
  * worktree executed as the daemon user, outside the sandbox, and read
15
- * `~/.ssh/config` and `~/.harmony-mcp/config.json`. So writing this hook into a
15
+ * `~/.ssh/config` and `~/.hmy/agent/config.json`. So writing this hook into a
16
16
  * project settings file would throw `ProjectSandboxOverrideError` on every
17
17
  * contained implement run in that repo — it would not degrade, it would stop
18
18
  * the daemon.
@@ -33,7 +33,7 @@ interface RefreshResponse {
33
33
  let inFlight: Promise<string | null> | null = null;
34
34
 
35
35
  // Cross-process serialization. Every Claude Code session spawns its own stdio
36
- // MCP process, and they all share ~/.harmony-mcp/config.json. The refresh
36
+ // MCP process, and they all share ~/.hmy/agent/config.json. The refresh
37
37
  // token rotates on use, so two processes refreshing concurrently would each
38
38
  // POST a refresh: the first consumes the token, the second replays a now-
39
39
  // consumed token and trips the server's reuse detection, revoking the whole
@@ -65,8 +65,15 @@ export type WithholdReason = "sensitive-path";
65
65
  * whole segment is sensitive regardless of the file name inside it.
66
66
  *
67
67
  * Kept in step with `credentialDirectories()` in the harness — see the module
68
- * doc. `.harmony-mcp` is here for the same reason it is first there: it holds
69
- * this product's own API key.
68
+ * doc. `.hmy` is here for the same reason it is first there: it holds this
69
+ * product's own API key. `.harmony-mcp` is its pre-#1082 name and stays listed
70
+ * FOREVER — the rename moved the daemon's writes, not the operator's old
71
+ * directory, which keeps that key on disk until they delete it by hand.
72
+ *
73
+ * One segment covers `~/.hmy/agent` and every future sibling: the match below
74
+ * walks segments, so `.hmy` needs no `CONFIG_SCOPED_SEGMENTS` special case.
75
+ * Both names are dot-prefixed and product-specific, so neither can collide with
76
+ * a source directory the way the bare `gh` / `op` entries could.
70
77
  */
71
78
  const SENSITIVE_SEGMENTS: readonly string[] = [
72
79
  ".ssh",
@@ -76,6 +83,7 @@ const SENSITIVE_SEGMENTS: readonly string[] = [
76
83
  ".gemini",
77
84
  ".docker",
78
85
  ".kube",
86
+ ".hmy",
79
87
  ".harmony-mcp",
80
88
  ".password-store",
81
89
  // `~/.claude` holds `.credentials.json`, Claude Code's own OAuth token, and
@@ -125,7 +133,21 @@ export const HARNESS_CREDENTIAL_LEAVES: readonly {
125
133
  */
126
134
  readonly kind: "dir" | "file";
127
135
  }[] = [
128
- { path: ".harmony-mcp", kind: "dir" }, // getConfigDir()
136
+ // Three entries for one credential directory (#1082). `~/.harmony-mcp` was
137
+ // renamed to `~/.hmy/agent`, and this list does NOT follow `getConfigDir()` —
138
+ // it is hand-transcribed, so a rename here is a decision rather than a
139
+ // consequence. The decision is to ADD, never to swap: an operator's
140
+ // `~/.harmony-mcp` keeps its `config.json` (the Harmony API key) and its run
141
+ // logs until they delete it by hand, so dropping the old name would put the
142
+ // key back on the board.
143
+ //
144
+ // `.hmy` covers the whole tree, which is what `isSensitivePath` wants: it
145
+ // matches on path SEGMENTS, so a single segment needs no `CONFIG_SCOPED_
146
+ // SEGMENTS` special case the way `.config/gh` does. `.hmy/agent` is listed
147
+ // beside it so the mirror test walks the exact path the harness names.
148
+ { path: ".hmy", kind: "dir" }, // getHmyRootDir()
149
+ { path: ".hmy/agent", kind: "dir" }, // getConfigDir()
150
+ { path: ".harmony-mcp", kind: "dir" }, // getLegacyConfigDir()
129
151
  { path: ".claude", kind: "dir" },
130
152
  { path: ".claude.json", kind: "file" },
131
153
  { path: ".ssh", kind: "dir" },
package/src/run-state.ts CHANGED
@@ -14,7 +14,7 @@
14
14
  * server's memory, so the session identity has to travel **through the
15
15
  * filesystem**. That is what this module is.
16
16
  *
17
- * ## Why not `~/.harmony-mcp/`
17
+ * ## Why not `~/.hmy/agent/`
18
18
  *
19
19
  * `getConfigDir()` is the FIRST entry of `credentialDirectories()`
20
20
  * (`packages/harmony-harness/src/run-containment.ts`) and is read-denied to
package/src/server.ts CHANGED
@@ -1610,7 +1610,7 @@ export const TOOLS = {
1610
1610
  },
1611
1611
  harmony_update_agent_progress: {
1612
1612
  description:
1613
- "Update progress on an active agent session. Use to report progress percentage, current task, blockers, or status changes. Check the reply: `session: null` with `stopped: true` means a human stopped this card's run, so reporting progress will no longer open a session here (card #770) — stop work if you were mid-run, and read `recovery` for the one legitimate way back. Otherwise a session is always returned: this call opens one if none is live.",
1613
+ "Update progress on an active agent session. Use to report progress percentage, current task, blockers, or status changes. Check the reply: `session: null` with `stopped: true` means a human stopped this card's run, so reporting progress will no longer open a session here (card #770) — stop work if you were mid-run, and read `recovery` for the one legitimate way back. Otherwise a session is always returned: this call opens one if none is live. `sessionStale: true` means it opened one IN PLACE OF a session the inactivity sweep had closed (card #1067): nobody stopped you and the new row keeps the old one's steering channel and driver, so carry on — but the id changed, so poll harmony_get_pending_messages with the new `session.id` from this reply, not the one you were holding. `liveSessionIdChanged: true` is the weaker cousin: the live session is simply not the one you started, so use the new id, but do NOT assume steering survived — read `instruction`.",
1614
1614
  inputSchema: {
1615
1615
  type: "object",
1616
1616
  properties: {
@@ -3293,7 +3293,7 @@ export async function handleToolCall(
3293
3293
  // deliberately-set active project is a hard scope: `found` there means
3294
3294
  // the card was already in it, so there is nothing to change — and we
3295
3295
  // never let a read silently repoint a context the user chose (which,
3296
- // on the local stdio MCP, persists to ~/.harmony-mcp/config.json
3296
+ // on the local stdio MCP, persists to ~/.hmy/agent/config.json
3297
3297
  // across sessions). Confirm the target back either way so a
3298
3298
  // wrong-context resolve is visible immediately.
3299
3299
  const established = activeProjectId == null;
@@ -4379,6 +4379,77 @@ export async function handleToolCall(
4379
4379
  untrack(cardId, deps.getScopeId?.());
4380
4380
  }
4381
4381
 
4382
+ // Two DIFFERENT facts, and the earlier cut of #1067 merged them into one
4383
+ // sentence that was false in three reachable cases.
4384
+ //
4385
+ // `result.sessionStale` is the server's own verdict: it replaced a
4386
+ // recently-swept session AND carried its `steerable`/`driver` forward. Only
4387
+ // that verdict licenses saying the steering channel survived.
4388
+ //
4389
+ // The id comparison establishes strictly less — that the live session is
4390
+ // not the one this run recorded at start. It catches the case the flag
4391
+ // cannot: `flushMemoryActions` is fire-and-forget, sends
4392
+ // `implicitCreate: true` and discards its result, so when the sweep has
4393
+ // closed the row that bookkeeping write is what mints the replacement and
4394
+ // consumes the server's one `sessionStale`; this checkpoint then lands on a
4395
+ // live row under a new id with `created: false`. But an id change alone is
4396
+ // ALSO what `harmony_move_card` leaves behind — it ends the session and
4397
+ // untracks the card without calling `cleanupMemorySession`, so the recorded
4398
+ // id outlives a deliberate `completed` end — and what a lapsed recency
4399
+ // window leaves behind, where the new row genuinely is NOT steerable.
4400
+ // Claiming inheritance there tells a run its steering survived when it
4401
+ // did not, which suppresses the one recovery that works (an explicit
4402
+ // `harmony_start_agent_session` with `steerable: true`). Worse than
4403
+ // silence, so the two paths say different things.
4404
+ //
4405
+ // The RESYNC is unconditional and is a fix in its own right: this field
4406
+ // scopes working-memory writes (`sessionScopeFor`) and feeds comment
4407
+ // attribution, so a stale id there is wrong regardless of what is reported.
4408
+ const scopeId = deps.getScopeId?.();
4409
+ // `memorySessions` is keyed by card id ALONE at module scope, so on the
4410
+ // hosted transport a lookup can hand back another user's session for the
4411
+ // same card. Anything that CLAIMS the tracked session compares the scope
4412
+ // first — `chooseCommentSession` does, and #1035 is why.
4413
+ const ownSession =
4414
+ memSession && memSession.scopeId === scopeId ? memSession : undefined;
4415
+ const knownSessionId = ownSession?.agentSessionId;
4416
+ const liveSessionId = (result.session as { id?: string } | null)?.id;
4417
+ const idChangedUnderUs =
4418
+ knownSessionId !== undefined &&
4419
+ liveSessionId !== undefined &&
4420
+ liveSessionId !== knownSessionId;
4421
+ // Report once: the run now knows this id, so a later checkpoint on the
4422
+ // same session is silent again rather than crying change forever.
4423
+ if (idChangedUnderUs && ownSession && liveSessionId) {
4424
+ ownSession.agentSessionId = liveSessionId;
4425
+ }
4426
+ const newSessionId = liveSessionId ? ` (${liveSessionId})` : "";
4427
+ if (result.sessionStale) {
4428
+ return {
4429
+ success: true,
4430
+ midSessionLearnings: 0,
4431
+ ...result,
4432
+ instruction:
4433
+ "Your previous session had gone stale, so this report opened a new one in its place — nobody stopped you, and it kept the steering channel and driver of the run it replaced. Do NOT abandon the work. Poll harmony_get_pending_messages with the NEW session id" +
4434
+ newSessionId +
4435
+ ", not the one you were holding.",
4436
+ };
4437
+ }
4438
+ if (idChangedUnderUs) {
4439
+ // Everything this branch knows, and nothing it does not: no claim that
4440
+ // anything went stale, and none that any capability was preserved.
4441
+ return {
4442
+ success: true,
4443
+ midSessionLearnings: 0,
4444
+ ...result,
4445
+ liveSessionIdChanged: true,
4446
+ instruction:
4447
+ "The live session on this card is no longer the one you started — it may have been reopened, or deliberately ended and replaced. Nobody stopped you, so carry on, but poll harmony_get_pending_messages with the session id from this reply" +
4448
+ newSessionId +
4449
+ ". If steering matters to this run, do not assume it survived: start a session deliberately with harmony_start_agent_session and steerable: true.",
4450
+ };
4451
+ }
4452
+
4382
4453
  // Phase 0 (memory architecture v2): mid-session learning extraction removed.
4383
4454
  return { success: true, midSessionLearnings: 0, ...result };
4384
4455
  }
package/src/tui/setup.ts CHANGED
@@ -1552,6 +1552,9 @@ export async function runSetup(options: SetupOptions = {}): Promise<void> {
1552
1552
  }
1553
1553
  }
1554
1554
 
1555
+ /** Set once step 9 writes the repo pin, so the summary names the real file. */
1556
+ let writtenLocalConfigPath: string | null = null;
1557
+
1555
1558
  // Step 9: Save context \u2014 both local (cwd-scoped) and global (user default).
1556
1559
  // Local config only resolves when the server runs with this repo as cwd;
1557
1560
  // remote/OAuth connections and other cwds fall back to the global active
@@ -1562,9 +1565,15 @@ export async function runSetup(options: SetupOptions = {}): Promise<void> {
1562
1565
  const localConfig: { workspaceId?: string; projectId?: string } = {};
1563
1566
  if (selectedWorkspaceId) localConfig.workspaceId = selectedWorkspaceId;
1564
1567
  if (selectedProjectId) localConfig.projectId = selectedProjectId;
1565
- saveLocalConfig(localConfig, cwd);
1568
+ // Print the path `saveLocalConfig` ACTUALLY wrote, not a re-derived guess
1569
+ // (#1082). `getLocalConfigPath` is `<cwd>/.hmy.json` with no upward walk,
1570
+ // while the write targets the directory of the pin already in scope — which
1571
+ // may be an ancestor (#893). Run setup from `packages/foo` in a repo pinned
1572
+ // at its root and the two disagree, so this line reported a file that does
1573
+ // not exist. Returning the path is what removes the second derivation.
1574
+ writtenLocalConfigPath = saveLocalConfig(localConfig, cwd);
1566
1575
  console.log(
1567
- ` ${colors.success("\u2713")} ${colors.dim(formatPath(getLocalConfigPath(cwd), home))} ${colors.dim("(created)")}`,
1576
+ ` ${colors.success("\u2713")} ${colors.dim(formatPath(writtenLocalConfigPath, home))} ${colors.dim("(created)")}`,
1568
1577
  );
1569
1578
 
1570
1579
  // Mirror the choice into the GLOBAL default, explicitly (#893). The local
@@ -1629,7 +1638,7 @@ export async function runSetup(options: SetupOptions = {}): Promise<void> {
1629
1638
  }
1630
1639
  if (selectedWorkspaceId || selectedProjectId) {
1631
1640
  console.log(
1632
- ` Context: ${formatPath(getLocalConfigPath(cwd), home)}`,
1641
+ ` Context: ${formatPath(writtenLocalConfigPath ?? getLocalConfigPath(cwd), home)}`,
1633
1642
  );
1634
1643
  }
1635
1644
 
package/src/tui/writer.ts CHANGED
@@ -33,6 +33,16 @@ function ensureDir(dirPath: string): void {
33
33
  }
34
34
  }
35
35
 
36
+ /**
37
+ * Path fragments that mark a file as living in a Harmony config directory, and
38
+ * therefore as 0o600 rather than 0o644.
39
+ *
40
+ * `.hmy` covers `~/.hmy/agent/config.json` and the repo-local `.hmy.json` in one
41
+ * fragment. `.harmony-mcp` is the pre-#1082 spelling of both, kept so `setup`
42
+ * writing into an un-migrated directory still writes a private file.
43
+ */
44
+ const CONFIG_DIR_MARKERS: readonly string[] = [".hmy", ".harmony-mcp"];
45
+
36
46
  /**
37
47
  * Write a file, optionally skipping if exists
38
48
  */
@@ -49,7 +59,17 @@ export function writeFile(
49
59
 
50
60
  try {
51
61
  ensureDir(dirname(filePath));
52
- const defaultMode = filePath.includes(".harmony-mcp") ? 0o600 : 0o644;
62
+ // 0o600 for anything under a Harmony config directory — that is where the
63
+ // API key lives. BOTH names are tested (#1082): the rename to `~/.hmy`
64
+ // moved where setup writes, so matching the old name alone would have
65
+ // silently written the credential 0o644 (world-readable) at the new path,
66
+ // and nothing about a successful write would have said so. `setup` is also
67
+ // still able to write into a legacy directory that has not been migrated.
68
+ const defaultMode = CONFIG_DIR_MARKERS.some((marker) =>
69
+ filePath.includes(marker),
70
+ )
71
+ ? 0o600
72
+ : 0o644;
53
73
  const mode = options.mode ?? defaultMode;
54
74
  writeFileSync(filePath, content, { mode });
55
75
  if (options.mode !== undefined) {