@opengsd/gsd-pi 1.4.0-dev.5fc22c6f → 1.4.0-dev.c18009cd

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.
Files changed (135) hide show
  1. package/dist/resources/.managed-resources-content-hash +1 -1
  2. package/dist/resources/extensions/claude-code-cli/stream-adapter.js +58 -4
  3. package/dist/resources/extensions/gsd/artifact-verification.js +17 -5
  4. package/dist/resources/extensions/gsd/auto/closeout.js +45 -42
  5. package/dist/resources/extensions/gsd/auto/finalize.js +9 -0
  6. package/dist/resources/extensions/gsd/auto/orchestrator.js +1 -1
  7. package/dist/resources/extensions/gsd/auto/run-unit.js +5 -2
  8. package/dist/resources/extensions/gsd/auto/schedule-wakeup.js +3 -0
  9. package/dist/resources/extensions/gsd/auto.js +10 -3
  10. package/dist/resources/extensions/gsd/bootstrap/schedule-wakeup-tool.js +58 -9
  11. package/dist/resources/extensions/gsd/doctor-engine-checks.js +174 -16
  12. package/dist/resources/extensions/gsd/gsd-db.js +52 -0
  13. package/dist/resources/extensions/gsd/migrate-external.js +13 -4
  14. package/dist/resources/extensions/gsd/milestone-merge-transaction.js +3 -3
  15. package/dist/resources/extensions/gsd/repo-identity.js +16 -0
  16. package/dist/resources/extensions/gsd/root-write-leak-guard.js +4 -2
  17. package/dist/resources/extensions/gsd/unit-registry.js +27 -2
  18. package/dist/resources/extensions/gsd/worktree-lifecycle.js +67 -5
  19. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  20. package/dist/web/standalone/.next/BUILD_ID +1 -1
  21. package/dist/web/standalone/.next/app-path-routes-manifest.json +6 -6
  22. package/dist/web/standalone/.next/build-manifest.json +2 -2
  23. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  24. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  25. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  26. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  27. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  28. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  29. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  30. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  31. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  32. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  33. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  34. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  35. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  36. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  37. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  38. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  39. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  40. package/dist/web/standalone/.next/server/app/index.html +1 -1
  41. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  42. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  43. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  44. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  45. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  46. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  47. package/dist/web/standalone/.next/server/app-paths-manifest.json +6 -6
  48. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  49. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  50. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  51. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  52. package/integrations/hermes/CONTEXT.md +47 -0
  53. package/integrations/hermes/README.md +21 -0
  54. package/integrations/hermes/docs/issue-1162-grilling.md +197 -0
  55. package/integrations/hermes/docs/setup.md +4 -0
  56. package/integrations/hermes/open_gsd_hermes/commands.py +141 -0
  57. package/integrations/hermes/open_gsd_hermes/config.py +2 -0
  58. package/integrations/hermes/open_gsd_hermes/gsd_client.py +594 -31
  59. package/integrations/hermes/open_gsd_hermes/notifications.py +10 -1
  60. package/integrations/hermes/plugin.yaml +4 -0
  61. package/integrations/hermes/tests/test_config.py +6 -0
  62. package/integrations/hermes/tests/test_gsd_client_cache.py +3 -0
  63. package/integrations/hermes/tests/test_gsd_client_execute.py +35 -0
  64. package/integrations/hermes/tests/test_gsd_client_process_cwd.py +22 -0
  65. package/integrations/hermes/tests/test_gsd_client_timeout.py +98 -0
  66. package/integrations/hermes/tests/test_milestone_client.py +447 -0
  67. package/integrations/hermes/tests/test_new_milestone_command.py +313 -0
  68. package/integrations/hermes/tests/test_supervisor_fsm.py +40 -0
  69. package/package.json +1 -1
  70. package/packages/cloud-mcp-gateway/package.json +2 -2
  71. package/packages/contracts/package.json +1 -1
  72. package/packages/daemon/dist/session-manager.js +1 -1
  73. package/packages/daemon/dist/session-manager.js.map +1 -1
  74. package/packages/daemon/package.json +4 -4
  75. package/packages/gsd-agent-core/dist/session/agent-session-navigation.d.ts.map +1 -1
  76. package/packages/gsd-agent-core/dist/session/agent-session-navigation.js +4 -1
  77. package/packages/gsd-agent-core/dist/session/agent-session-navigation.js.map +1 -1
  78. package/packages/gsd-agent-core/package.json +5 -5
  79. package/packages/gsd-agent-modes/package.json +7 -7
  80. package/packages/mcp-server/dist/server.d.ts +18 -5
  81. package/packages/mcp-server/dist/server.d.ts.map +1 -1
  82. package/packages/mcp-server/dist/server.js +46 -5
  83. package/packages/mcp-server/dist/server.js.map +1 -1
  84. package/packages/mcp-server/dist/session-manager.js +1 -1
  85. package/packages/mcp-server/dist/session-manager.js.map +1 -1
  86. package/packages/mcp-server/package.json +4 -4
  87. package/packages/native/package.json +1 -1
  88. package/packages/pi-agent-core/package.json +1 -1
  89. package/packages/pi-ai/package.json +1 -1
  90. package/packages/pi-coding-agent/dist/core/session-manager-types.d.ts +1 -0
  91. package/packages/pi-coding-agent/dist/core/session-manager-types.d.ts.map +1 -1
  92. package/packages/pi-coding-agent/dist/core/session-manager-types.js.map +1 -1
  93. package/packages/pi-coding-agent/dist/core/session-manager.d.ts.map +1 -1
  94. package/packages/pi-coding-agent/dist/core/session-manager.js +3 -0
  95. package/packages/pi-coding-agent/dist/core/session-manager.js.map +1 -1
  96. package/packages/pi-coding-agent/package.json +7 -7
  97. package/packages/pi-tui/package.json +2 -2
  98. package/packages/rpc-client/package.json +2 -2
  99. package/pkg/package.json +1 -1
  100. package/src/resources/extensions/claude-code-cli/stream-adapter.ts +64 -3
  101. package/src/resources/extensions/claude-code-cli/tests/stream-adapter.test.ts +212 -39
  102. package/src/resources/extensions/gsd/artifact-verification.ts +16 -4
  103. package/src/resources/extensions/gsd/auto/closeout.ts +54 -56
  104. package/src/resources/extensions/gsd/auto/finalize.ts +8 -0
  105. package/src/resources/extensions/gsd/auto/loop-deps.ts +1 -0
  106. package/src/resources/extensions/gsd/auto/orchestrator.ts +1 -1
  107. package/src/resources/extensions/gsd/auto/run-unit.ts +5 -2
  108. package/src/resources/extensions/gsd/auto/schedule-wakeup.ts +8 -0
  109. package/src/resources/extensions/gsd/auto.ts +9 -3
  110. package/src/resources/extensions/gsd/bootstrap/schedule-wakeup-tool.ts +80 -12
  111. package/src/resources/extensions/gsd/doctor-engine-checks.ts +230 -21
  112. package/src/resources/extensions/gsd/gsd-db.ts +72 -0
  113. package/src/resources/extensions/gsd/migrate-external.ts +14 -4
  114. package/src/resources/extensions/gsd/milestone-merge-transaction.ts +4 -4
  115. package/src/resources/extensions/gsd/repo-identity.ts +17 -0
  116. package/src/resources/extensions/gsd/root-write-leak-guard.ts +3 -2
  117. package/src/resources/extensions/gsd/tests/auto-loop.test.ts +131 -5
  118. package/src/resources/extensions/gsd/tests/auto-phases-lifecycle.test.ts +43 -1
  119. package/src/resources/extensions/gsd/tests/auto-recovery.test.ts +54 -0
  120. package/src/resources/extensions/gsd/tests/doctor-scope-db-unavailable.test.ts +335 -0
  121. package/src/resources/extensions/gsd/tests/merge-conflict-stops-loop.test.ts +4 -4
  122. package/src/resources/extensions/gsd/tests/migrate-external-worktree.test.ts +116 -0
  123. package/src/resources/extensions/gsd/tests/milestone-merge-stash-restore.test.ts +152 -22
  124. package/src/resources/extensions/gsd/tests/milestone-merge-transaction.test.ts +1 -1
  125. package/src/resources/extensions/gsd/tests/phases-merge-error-stops-auto.test.ts +50 -6
  126. package/src/resources/extensions/gsd/tests/root-write-leak-guard.test.ts +31 -9
  127. package/src/resources/extensions/gsd/tests/schedule-wakeup-tool.test.ts +167 -0
  128. package/src/resources/extensions/gsd/tests/state-corruption-2945.test.ts +1 -1
  129. package/src/resources/extensions/gsd/tests/unit-registry.test.ts +56 -0
  130. package/src/resources/extensions/gsd/tests/worktree-journal-events.test.ts +4 -4
  131. package/src/resources/extensions/gsd/tests/worktree-lifecycle.test.ts +2 -2
  132. package/src/resources/extensions/gsd/unit-registry.ts +34 -2
  133. package/src/resources/extensions/gsd/worktree-lifecycle.ts +135 -11
  134. /package/dist/web/standalone/.next/static/{kIEFsCryyGy6RzifULbJl → L2UMqXELiDH3oz-7yguXp}/_buildManifest.js +0 -0
  135. /package/dist/web/standalone/.next/static/{kIEFsCryyGy6RzifULbJl → L2UMqXELiDH3oz-7yguXp}/_ssgManifest.js +0 -0
@@ -8,7 +8,7 @@ import subprocess
8
8
  import threading
9
9
  import time
10
10
  from dataclasses import dataclass, field
11
- from typing import Any
11
+ from typing import Any, Callable
12
12
 
13
13
  from packaging import version
14
14
 
@@ -36,10 +36,29 @@ class GsdMcpClient:
36
36
  def __init__(self, config: GsdConfig) -> None:
37
37
  self._config = config
38
38
  self._proc: subprocess.Popen[bytes] | None = None
39
+ self._proc_cwd: str | None = None
40
+ self._stdout_buffer = b""
39
41
  self._lock = threading.Lock()
40
42
  self._request_id = 0
41
43
  self._cache: dict[str, _CacheEntry] = {}
42
44
  self._version_checked = False
45
+ # Milestone subprocess state (issue #1162). The plugin owns the
46
+ # `gsd headless --supervised new-milestone` process directly, so the
47
+ # MCP session manager cannot see or reach it. This client is the sole
48
+ # owner of its lifecycle (cancel, blocker replies, terminal events).
49
+ self._milestone_proc: subprocess.Popen[bytes] | None = None
50
+ self._milestone_session_id: str | None = None
51
+ self._milestone_pending_blocker_id: str | None = None
52
+ self._milestone_pending_blocker_method: str | None = None
53
+ self._milestone_command_block_failure: str | None = None
54
+ self._milestone_notifications: Any = None # NotificationService | None
55
+ self._milestone_project_dir: str | None = None
56
+ self._milestone_on_terminal: Callable[[str], None] | None = None
57
+ self._milestone_notified_terminal: bool = False
58
+ self._milestone_thread: threading.Thread | None = None
59
+ self._milestone_stderr_thread: threading.Thread | None = None
60
+ self._milestone_stderr_lock = threading.Lock()
61
+ self._milestone_stderr_tail = bytearray()
43
62
 
44
63
  def _env(self) -> dict[str, str]:
45
64
  env = os.environ.copy()
@@ -72,42 +91,129 @@ class GsdMcpClient:
72
91
  )
73
92
  self._version_checked = True
74
93
 
75
- def _ensure_process(self) -> subprocess.Popen[bytes]:
94
+ def _ensure_process(self, project_dir: str | None = None) -> subprocess.Popen[bytes]:
95
+ cwd = (
96
+ os.path.abspath(project_dir)
97
+ if project_dir is not None
98
+ else self._proc_cwd
99
+ )
76
100
  if self._proc is not None and self._proc.poll() is None:
77
- return self._proc
101
+ if cwd is None or self._proc_cwd == cwd:
102
+ return self._proc
103
+ self._terminate_process()
104
+ self._stdout_buffer = b""
105
+ self._proc_cwd = cwd
78
106
  self._proc = subprocess.Popen(
79
107
  [self._config.mcp_server_path],
80
108
  stdin=subprocess.PIPE,
81
109
  stdout=subprocess.PIPE,
82
110
  stderr=subprocess.PIPE,
83
111
  env=self._env(),
112
+ cwd=cwd,
84
113
  )
85
- self._initialize()
114
+ try:
115
+ self._initialize()
116
+ except Exception:
117
+ self._terminate_process()
118
+ raise
86
119
  return self._proc
87
120
 
88
- def _send(self, payload: dict[str, Any]) -> None:
89
- proc = self._ensure_process()
121
+ def _terminate_process(self) -> None:
122
+ proc = self._proc
123
+ self._proc = None
124
+ self._proc_cwd = None
125
+ self._stdout_buffer = b""
126
+ if proc is None or proc.poll() is not None:
127
+ return
128
+ proc.terminate()
129
+ try:
130
+ proc.wait(timeout=5)
131
+ except subprocess.TimeoutExpired:
132
+ proc.kill()
133
+
134
+ def _send(self, payload: dict[str, Any], *, project_dir: str | None = None) -> None:
135
+ proc = self._ensure_process(project_dir)
90
136
  assert proc.stdin is not None
91
137
  body = json.dumps(payload).encode("utf-8")
92
138
  header = f"Content-Length: {len(body)}\r\n\r\n".encode("ascii")
93
139
  proc.stdin.write(header + body)
94
140
  proc.stdin.flush()
95
141
 
96
- def _read_message(self) -> dict[str, Any]:
97
- proc = self._ensure_process()
142
+ def _read_stdout_chunk(self, deadline: float) -> None:
143
+ proc = self._proc
144
+ if proc is None or proc.poll() is not None:
145
+ self._terminate_process()
146
+ raise McpProtocolError("MCP server closed stdout")
98
147
  assert proc.stdout is not None
99
- headers: dict[str, str] = {}
148
+ remaining = deadline - time.monotonic()
149
+ if remaining <= 0:
150
+ self._raise_read_timeout()
151
+
152
+ chunks: list[bytes] = []
153
+ errors: list[BaseException] = []
154
+
155
+ def read_chunk() -> None:
156
+ try:
157
+ chunks.append(os.read(proc.stdout.fileno(), 65536))
158
+ except BaseException as e:
159
+ errors.append(e)
160
+
161
+ reader = threading.Thread(target=read_chunk, daemon=True)
162
+ reader.start()
163
+ reader.join(remaining)
164
+ if reader.is_alive():
165
+ self._raise_read_timeout()
166
+ if errors:
167
+ self._terminate_process()
168
+ raise McpProtocolError(f"MCP server stdout read failed: {errors[0]}")
169
+ chunk = chunks[0] if chunks else b""
170
+ if not chunk:
171
+ self._terminate_process()
172
+ raise McpProtocolError("MCP server closed stdout")
173
+ self._stdout_buffer += chunk
174
+
175
+ def _raise_read_timeout(self) -> None:
176
+ self._terminate_process()
177
+ raise McpProtocolError(
178
+ f"MCP server response timed out after "
179
+ f"{self._config.mcp_read_timeout_seconds:g}s"
180
+ )
181
+
182
+ def _read_header(self, deadline: float) -> bytes:
100
183
  while True:
101
- line = proc.stdout.readline()
102
- if not line:
103
- raise McpProtocolError("MCP server closed stdout")
104
- decoded = line.decode("utf-8").strip()
105
- if decoded == "":
106
- break
184
+ crlf_idx = self._stdout_buffer.find(b"\r\n\r\n")
185
+ lf_idx = self._stdout_buffer.find(b"\n\n")
186
+ candidates = [
187
+ (idx, sep_len)
188
+ for idx, sep_len in ((crlf_idx, 4), (lf_idx, 2))
189
+ if idx >= 0
190
+ ]
191
+ if candidates:
192
+ idx, sep_len = min(candidates)
193
+ header = self._stdout_buffer[:idx]
194
+ self._stdout_buffer = self._stdout_buffer[idx + sep_len :]
195
+ return header
196
+ self._read_stdout_chunk(deadline)
197
+
198
+ def _read_exact(self, length: int, deadline: float) -> bytes:
199
+ while len(self._stdout_buffer) < length:
200
+ self._read_stdout_chunk(deadline)
201
+ body = self._stdout_buffer[:length]
202
+ self._stdout_buffer = self._stdout_buffer[length:]
203
+ return body
204
+
205
+ def _read_message(self) -> dict[str, Any]:
206
+ deadline = time.monotonic() + self._config.mcp_read_timeout_seconds
207
+ header = self._read_header(deadline)
208
+ headers: dict[str, str] = {}
209
+ for raw_line in header.splitlines():
210
+ decoded = raw_line.decode("utf-8").strip()
211
+ if not decoded:
212
+ continue
107
213
  key, _, val = decoded.partition(":")
108
214
  headers[key.strip().lower()] = val.strip()
109
215
  length = int(headers.get("content-length", "0"))
110
- body = proc.stdout.read(length)
216
+ body = self._read_exact(length, deadline)
111
217
  return json.loads(body.decode("utf-8"))
112
218
 
113
219
  def _initialize(self) -> None:
@@ -128,7 +234,12 @@ class GsdMcpClient:
128
234
  self._send({"jsonrpc": "2.0", "method": "notifications/initialized"})
129
235
 
130
236
  def _call_tool(
131
- self, name: str, arguments: dict[str, Any], *, check_version: bool = True
237
+ self,
238
+ name: str,
239
+ arguments: dict[str, Any],
240
+ *,
241
+ check_version: bool = True,
242
+ project_dir: str | None = None,
132
243
  ) -> dict[str, Any]:
133
244
  with self._lock:
134
245
  if check_version:
@@ -141,7 +252,8 @@ class GsdMcpClient:
141
252
  "id": req_id,
142
253
  "method": "tools/call",
143
254
  "params": {"name": name, "arguments": arguments},
144
- }
255
+ },
256
+ project_dir=project_dir,
145
257
  )
146
258
  while True:
147
259
  msg = self._read_message()
@@ -219,6 +331,7 @@ class GsdMcpClient:
219
331
  "gsd_progress",
220
332
  {"projectDir": project_dir},
221
333
  check_version=False,
334
+ project_dir=project_dir,
222
335
  )
223
336
  return ProgressSnapshot.from_mcp(data)
224
337
 
@@ -231,21 +344,36 @@ class GsdMcpClient:
231
344
 
232
345
  def execute(self, project_dir: str, *, command: str = "/gsd auto") -> dict[str, Any]:
233
346
  self.invalidate_cache(project_dir)
234
- return self._call_tool(
235
- "gsd_execute",
236
- {"projectDir": project_dir, "command": command},
237
- )
347
+ args = {"projectDir": project_dir, "command": command}
348
+ try:
349
+ return self._call_tool("gsd_execute", args, project_dir=project_dir)
350
+ except GsdVersionError:
351
+ return self._call_tool(
352
+ "gsd_execute",
353
+ args,
354
+ check_version=False,
355
+ project_dir=project_dir,
356
+ )
238
357
 
239
- def cancel(self, *, session_id: str | None = None, project_dir: str | None = None) -> dict[str, Any]:
358
+ def cancel(
359
+ self,
360
+ *,
361
+ session_id: str | None = None,
362
+ project_dir: str | None = None,
363
+ ) -> dict[str, Any]:
240
364
  args: dict[str, Any] = {}
241
365
  if session_id:
242
366
  args["sessionId"] = session_id
243
367
  if project_dir:
244
368
  args["projectDir"] = project_dir
245
- return self._call_tool("gsd_cancel", args)
369
+ return self._call_tool("gsd_cancel", args, project_dir=project_dir)
246
370
 
247
371
  def cancel_by_project(self, project_dir: str) -> dict[str, Any]:
248
- return self._call_tool("gsd_cancel_by_project", {"projectDir": project_dir})
372
+ return self._call_tool(
373
+ "gsd_cancel_by_project",
374
+ {"projectDir": project_dir},
375
+ project_dir=project_dir,
376
+ )
249
377
 
250
378
  def resolve_blocker(self, session_id: str, response: str) -> dict[str, Any]:
251
379
  return self._call_tool(
@@ -257,13 +385,448 @@ class GsdMcpClient:
257
385
  return self._call_tool(
258
386
  "gsd_memory_query",
259
387
  {"projectDir": project_dir, "query": query},
388
+ project_dir=project_dir,
260
389
  )
261
390
 
262
- def close(self) -> None:
263
- if self._proc and self._proc.poll() is None:
264
- self._proc.terminate()
391
+ def project_query(self, project_dir: str, query: str) -> dict[str, Any]:
392
+ return self._call_tool(
393
+ "gsd_query",
394
+ {"projectDir": project_dir, "query": query},
395
+ )
396
+
397
+ # ------------------------------------------------------------------
398
+ # Milestone creation (issue #1162)
399
+ #
400
+ # The plugin spawns `gsd headless --supervised new-milestone` directly
401
+ # and owns the subprocess. The MCP session manager cannot see it (it
402
+ # only knows sessions it started via gsd_execute), and there is no
403
+ # auto.lock pid backstop during the planning phase. So this client is
404
+ # the sole owner of the subprocess's lifecycle: cancel (SIGTERM),
405
+ # blocker replies (stdin), and transition/terminal notifications
406
+ # (stdout stream). See integrations/hermes/docs/issue-1162-grilling.md.
407
+ # ------------------------------------------------------------------
408
+
409
+ # Stop-notice vocabulary mirrored from src/resources/extensions/gsd/stop-notice.ts.
410
+ # Keep both sides in lockstep; the canonical prefixes the headless event
411
+ # loop emits in notify messages. Terminal detection is handled by process
412
+ # exit (the stream reader emits notify_terminal on stdout EOF), so only the
413
+ # blocked-notice classifier is needed here.
414
+ _PAUSED_NOTICE_PREFIXES = ("auto-mode paused", "step-mode paused")
415
+ _FIRE_AND_FORGET_UI_METHODS = frozenset(
416
+ {"notify", "setStatus", "setWidget", "setTitle", "set_editor_text"}
417
+ )
418
+ _EXIT_BLOCKED = 10
419
+ _STDERR_TAIL_LIMIT = 64 * 1024
420
+ # Supervised headless defaults to 30s; chat /gsd reply needs much longer.
421
+ _SUPERVISED_RESPONSE_TIMEOUT_MS = 3_600_000
422
+
423
+ def _is_blocked_notice(self, message: str) -> bool:
424
+ """Mirror of stop-notice.ts isBlockedNoticeMessage (lowercased input)."""
425
+ if "blocked:" in message:
426
+ return True
427
+ if any(message.startswith(p) for p in self._PAUSED_NOTICE_PREFIXES):
428
+ if "idempotent advance: unit already active" not in message:
429
+ return True
430
+ return (
431
+ "resolve manually and re-run /gsd auto" in message
432
+ or "resolve conflicts manually and run /gsd auto to resume" in message
433
+ or "resolve and run /gsd auto to resume" in message
434
+ )
435
+
436
+ def _is_milestone_blocker_event(self, event: dict[str, Any]) -> bool:
437
+ """True for supervised interactive UI requests (select/input/confirm/editor)."""
438
+ method = str(event.get("method") or "")
439
+ if method in self._FIRE_AND_FORGET_UI_METHODS:
440
+ return False
441
+ return True
442
+
443
+ def _get_command_block_content(self, event: dict[str, Any]) -> str | None:
444
+ """Extract gsd-command-block message content from stream events."""
445
+ etype = event.get("type")
446
+ if etype not in ("message_start", "message_end"):
447
+ return None
448
+ message = event.get("message")
449
+ if not isinstance(message, dict):
450
+ return None
451
+ if message.get("customType") != "gsd-command-block":
452
+ return None
453
+ return str(message.get("content") or "")
454
+
455
+ def _is_blocking_command_block(self, event: dict[str, Any]) -> bool:
456
+ """Mirror headless-events isBlockingCommandBlock."""
457
+ content = self._get_command_block_content(event)
458
+ if not content:
459
+ return False
460
+ lowered = content.lower()
461
+ return (
462
+ (
463
+ "cannot start new workflow work" in lowered
464
+ and "complete but not merged" in lowered
465
+ )
466
+ or "cannot run because the active milestone is blocked by validation"
467
+ in lowered
468
+ )
469
+
470
+ def _parse_confirm_response(self, response: str) -> bool:
471
+ normalized = response.strip().lower()
472
+ if normalized in ("no", "n", "false", "0", "cancel", "decline"):
473
+ return False
474
+ return True
475
+
476
+ def create_milestone(
477
+ self,
478
+ project_dir: str,
479
+ *,
480
+ context_text: str | None = None,
481
+ context_file: str | None = None,
482
+ notifications: Any = None,
483
+ on_terminal: Callable[[str], None] | None = None,
484
+ ) -> str:
485
+ """Spawn `gsd headless --supervised new-milestone` and return a session id.
486
+
487
+ The subprocess is started detached; a background thread reads its
488
+ stream-json stdout and drives the supplied NotificationService on
489
+ blocker/terminal events. Returns a local session id immediately
490
+ (headless does not emit init_result on stdout).
491
+
492
+ Raises ValueError if neither (or both) of context_text/context_file
493
+ is supplied.
494
+ """
495
+ if bool(context_text) == bool(context_file):
496
+ raise ValueError(
497
+ "Provide exactly one milestone spec: context_text or context_file"
498
+ )
499
+ self.ensure_version()
500
+ if self._milestone_proc is not None and self._milestone_proc.poll() is None:
501
+ raise RuntimeError("Milestone creation already in progress.")
502
+
503
+ args: list[str] = [
504
+ self._config.cli_path,
505
+ "headless",
506
+ "--supervised",
507
+ "--response-timeout",
508
+ str(self._SUPERVISED_RESPONSE_TIMEOUT_MS),
509
+ "--output-format",
510
+ "stream-json",
511
+ ]
512
+ if context_text:
513
+ args += ["--context-text", context_text]
514
+ else:
515
+ assert context_file is not None
516
+ args += ["--context", context_file]
517
+ args.append("new-milestone")
518
+
519
+ proc = subprocess.Popen( # noqa: S603 - args list, not shell
520
+ args,
521
+ stdin=subprocess.PIPE,
522
+ stdout=subprocess.PIPE,
523
+ stderr=subprocess.PIPE,
524
+ cwd=project_dir,
525
+ env=self._env(),
526
+ )
527
+ self._milestone_proc = proc
528
+ self._milestone_session_id = None
529
+ self._milestone_pending_blocker_id = None
530
+ self._milestone_pending_blocker_method = None
531
+ self._milestone_command_block_failure = None
532
+ self._milestone_notifications = notifications
533
+ self._milestone_project_dir = project_dir
534
+ self._milestone_on_terminal = on_terminal
535
+ self._milestone_notified_terminal = False
536
+ self._clear_milestone_stderr()
537
+ self._milestone_stderr_thread = threading.Thread(
538
+ target=self._drain_milestone_stderr,
539
+ args=(proc,),
540
+ daemon=True,
541
+ )
542
+ self._milestone_stderr_thread.start()
543
+
544
+ # Headless stream-json does not emit init_result on stdout (init is an
545
+ # internal RPC handshake). Use a local session id for ack/status only;
546
+ # milestone lifecycle is keyed on the held subprocess, not MCP.
547
+ session_id = f"milestone-{proc.pid}"
548
+ self._milestone_session_id = session_id
549
+
550
+ self._milestone_thread = threading.Thread(
551
+ target=self._milestone_stream_loop,
552
+ args=(proc,),
553
+ daemon=True,
554
+ )
555
+ self._milestone_thread.start()
556
+ return session_id
557
+
558
+ def _milestone_stream_loop(self, proc: subprocess.Popen[bytes]) -> None:
559
+ """Background reader: capture blocker/terminal events and notify.
560
+
561
+ Best-effort. On stream loss we surface FAILED via notify_terminal so
562
+ the user can `/gsd cancel` to clean up (no watchdog; the cancel
563
+ escape hatch is the safety net).
564
+ """
565
+ assert proc.stdout is not None
566
+ try:
567
+ while True:
568
+ line = proc.stdout.readline()
569
+ if not line:
570
+ break
571
+ try:
572
+ event = json.loads(line.decode("utf-8"))
573
+ except (json.JSONDecodeError, UnicodeDecodeError):
574
+ continue
575
+ if self._milestone_proc is not proc:
576
+ return
577
+ self._handle_milestone_event(event)
578
+ except Exception:
579
+ pass
580
+ if self._milestone_proc is not proc:
581
+ return
582
+ if self._milestone_notified_terminal:
583
+ if proc.poll() is not None:
584
+ self._clear_milestone_state()
585
+ return
586
+ exit_code = proc.poll()
587
+ if exit_code is None:
265
588
  try:
266
- self._proc.wait(timeout=5)
589
+ exit_code = proc.wait(timeout=5)
267
590
  except subprocess.TimeoutExpired:
268
- self._proc.kill()
269
- self._proc = None
591
+ exit_code = None
592
+ if exit_code == self._EXIT_BLOCKED:
593
+ if not self._milestone_notified_terminal:
594
+ if self._milestone_notifications is not None:
595
+ self._milestone_notifications.notify_terminal(
596
+ "failed", "Planning blocked"
597
+ )
598
+ if self._milestone_on_terminal is not None:
599
+ self._milestone_on_terminal("failed")
600
+ self._milestone_notified_terminal = True
601
+ self._clear_milestone_state()
602
+ return
603
+ if exit_code == 0:
604
+ if self._milestone_command_block_failure:
605
+ status = "failed"
606
+ error = self._milestone_command_block_failure
607
+ else:
608
+ status = "complete"
609
+ error = None
610
+ elif exit_code is not None:
611
+ status = "failed"
612
+ self._join_milestone_stderr_thread()
613
+ error = self._milestone_stderr_text(proc) or f"exit code {exit_code}"
614
+ else:
615
+ status = "failed"
616
+ error = "stream lost — run /gsd cancel"
617
+ if self._milestone_notifications is not None:
618
+ if status == "complete":
619
+ summary = self._build_milestone_completion_message(
620
+ self._milestone_project_dir or ""
621
+ )
622
+ self._milestone_notifications.notify_milestone_complete(summary)
623
+ else:
624
+ self._milestone_notifications.notify_terminal(status, error)
625
+ if self._milestone_on_terminal is not None:
626
+ self._milestone_on_terminal(status)
627
+ self._milestone_notified_terminal = True
628
+ if exit_code is not None:
629
+ self._clear_milestone_state()
630
+ else:
631
+ self._milestone_pending_blocker_id = None
632
+ self._milestone_pending_blocker_method = None
633
+
634
+ def _drain_milestone_stderr(self, proc: subprocess.Popen[bytes]) -> None:
635
+ """Drain stderr so a full pipe cannot stall the child process."""
636
+ if proc.stderr is None:
637
+ return
638
+ try:
639
+ while True:
640
+ chunk = proc.stderr.read(4096)
641
+ if not chunk:
642
+ break
643
+ self._append_milestone_stderr(chunk)
644
+ except Exception:
645
+ pass
646
+
647
+ def _append_milestone_stderr(self, chunk: bytes) -> None:
648
+ with self._milestone_stderr_lock:
649
+ self._milestone_stderr_tail.extend(chunk)
650
+ overflow = len(self._milestone_stderr_tail) - self._STDERR_TAIL_LIMIT
651
+ if overflow > 0:
652
+ del self._milestone_stderr_tail[:overflow]
653
+
654
+ def _clear_milestone_stderr(self) -> None:
655
+ with self._milestone_stderr_lock:
656
+ self._milestone_stderr_tail.clear()
657
+
658
+ def _join_milestone_stderr_thread(self) -> None:
659
+ thread = self._milestone_stderr_thread
660
+ if thread and thread is not threading.current_thread():
661
+ thread.join(timeout=1)
662
+
663
+ def _milestone_stderr_text(self, proc: subprocess.Popen[bytes]) -> str:
664
+ with self._milestone_stderr_lock:
665
+ err_bytes = bytes(self._milestone_stderr_tail)
666
+ if not err_bytes and proc.stderr is not None:
667
+ try:
668
+ err_bytes = proc.stderr.read()
669
+ except Exception:
670
+ err_bytes = b""
671
+ return err_bytes.decode("utf-8", errors="replace").strip()
672
+
673
+ def _handle_milestone_event(self, event: dict[str, Any]) -> None:
674
+ etype = event.get("type")
675
+ if etype == "init_result":
676
+ sid = event.get("sessionId")
677
+ if sid:
678
+ self._milestone_session_id = str(sid)
679
+ return
680
+ if self._is_blocking_command_block(event):
681
+ failure = self._get_command_block_content(event) or "Planning blocked"
682
+ self._milestone_command_block_failure = failure
683
+ if not self._milestone_notified_terminal:
684
+ if self._milestone_notifications is not None:
685
+ self._milestone_notifications.notify_terminal("failed", failure)
686
+ if self._milestone_on_terminal is not None:
687
+ self._milestone_on_terminal("failed")
688
+ self._milestone_notified_terminal = True
689
+ return
690
+ if etype != "extension_ui_request":
691
+ return
692
+ if not self._is_milestone_blocker_event(event):
693
+ return
694
+ event_id = str(event.get("id") or "")
695
+ self._milestone_pending_blocker_id = event_id or None
696
+ self._milestone_pending_blocker_method = str(event.get("method") or "") or None
697
+ if self._milestone_notifications is not None:
698
+ self._milestone_notifications.notify_blocker(
699
+ SessionStatus(
700
+ status="blocked",
701
+ pending_blocker=event,
702
+ session_id=self._milestone_session_id,
703
+ )
704
+ )
705
+
706
+ def cancel_milestone(self) -> None:
707
+ """SIGTERM the held milestone subprocess and clear state."""
708
+ proc = self._milestone_proc
709
+ if proc is None:
710
+ return
711
+ self._milestone_notified_terminal = True
712
+ try:
713
+ proc.terminate()
714
+ except Exception:
715
+ pass
716
+ try:
717
+ proc.wait(timeout=5)
718
+ except subprocess.TimeoutExpired:
719
+ try:
720
+ proc.kill()
721
+ except Exception:
722
+ pass
723
+ if self._milestone_proc is proc:
724
+ self._clear_milestone_state()
725
+
726
+ def _release_milestone_proc(self) -> None:
727
+ self._milestone_proc = None
728
+ self._milestone_notifications = None
729
+ self._milestone_on_terminal = None
730
+ self._milestone_stderr_thread = None
731
+ self._clear_milestone_stderr()
732
+
733
+ def _build_milestone_completion_message(self, project_dir: str) -> str:
734
+ fallback = "✅ Milestone created. Run `/gsd status` for details."
735
+ if not project_dir:
736
+ return fallback
737
+ try:
738
+ query_data = self.project_query(project_dir, "milestones")
739
+ except Exception:
740
+ return fallback
741
+ milestones = query_data.get("milestones")
742
+ if not milestones:
743
+ return fallback
744
+ try:
745
+ self.invalidate_cache(project_dir)
746
+ snap = self.progress(project_dir)
747
+ except Exception:
748
+ snap = None
749
+ milestone_id: str | None = None
750
+ if snap and snap.active_milestone:
751
+ milestone_id = snap.active_milestone.get("id")
752
+ if not milestone_id:
753
+ last = milestones[-1]
754
+ if isinstance(last, dict):
755
+ milestone_id = last.get("id")
756
+ if not milestone_id:
757
+ return fallback
758
+ slice_total = snap.slices.get("total", 0) if snap else 0
759
+ task_total = snap.tasks.get("total", 0) if snap else 0
760
+ msg = f"✅ Milestone {milestone_id} ready"
761
+ counts: list[str] = []
762
+ if slice_total:
763
+ counts.append(f"{slice_total} slice{'s' if slice_total != 1 else ''}")
764
+ if task_total:
765
+ counts.append(f"{task_total} task{'s' if task_total != 1 else ''}")
766
+ if counts:
767
+ msg += f" — {', '.join(counts)}"
768
+ return f"{msg}. Run `/gsd auto` to start."
769
+
770
+ def _clear_milestone_state(self) -> None:
771
+ self._release_milestone_proc()
772
+ self._milestone_session_id = None
773
+ self._milestone_project_dir = None
774
+ self._milestone_pending_blocker_id = None
775
+ self._milestone_pending_blocker_method = None
776
+ self._milestone_command_block_failure = None
777
+
778
+ def respond_to_milestone_blocker(self, response: str) -> None:
779
+ """Write an extension_ui_response to the milestone subprocess stdin.
780
+
781
+ Uses the supervised-mode JSONL protocol (see startSupervisedStdinReader
782
+ in src/headless-ui.ts): {"type":"extension_ui_response","id":...,"value":...}
783
+ or {"type":"extension_ui_response","id":...,"confirmed":...} for confirm prompts.
784
+ """
785
+ proc = self._milestone_proc
786
+ blocker_id = self._milestone_pending_blocker_id
787
+ if self._milestone_notified_terminal:
788
+ raise RuntimeError(
789
+ "Milestone session is no longer accepting replies. "
790
+ "Run `/gsd cancel` to clean up."
791
+ )
792
+ if proc is None or not blocker_id:
793
+ raise RuntimeError(
794
+ "No pending blocker for the active milestone session."
795
+ )
796
+ assert proc.stdin is not None
797
+ response_payload: dict[str, Any] = {
798
+ "type": "extension_ui_response",
799
+ "id": blocker_id,
800
+ }
801
+ if self._milestone_pending_blocker_method == "confirm":
802
+ response_payload["confirmed"] = self._parse_confirm_response(response)
803
+ else:
804
+ response_payload["value"] = response
805
+ payload = json.dumps(response_payload).encode("utf-8")
806
+ proc.stdin.write(payload + b"\n")
807
+ proc.stdin.flush()
808
+ self._milestone_pending_blocker_id = None
809
+ self._milestone_pending_blocker_method = None
810
+
811
+ def milestone_active(self) -> bool:
812
+ proc = self._milestone_proc
813
+ if proc is None:
814
+ return False
815
+ thread = self._milestone_thread
816
+ if thread is not None and thread.is_alive():
817
+ return True
818
+ if proc.poll() is None:
819
+ return True
820
+ self._clear_milestone_state()
821
+ return False
822
+
823
+ def milestone_session_id(self) -> str | None:
824
+ return self._milestone_session_id
825
+
826
+ def milestone_pending_blocker_id(self) -> str | None:
827
+ return self._milestone_pending_blocker_id
828
+
829
+ def close(self) -> None:
830
+ self._terminate_process()
831
+ if self._milestone_proc is not None:
832
+ self.cancel_milestone()