gsd-pi 2.44.0-dev.0b97ffd → 2.44.0-dev.73f2fd5

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 (173) hide show
  1. package/dist/resources/extensions/gsd/auto/infra-errors.js +3 -0
  2. package/dist/resources/extensions/gsd/auto/phases.js +36 -36
  3. package/dist/resources/extensions/gsd/auto-prompts.js +24 -1
  4. package/dist/resources/extensions/gsd/auto-timers.js +57 -3
  5. package/dist/resources/extensions/gsd/auto-worktree-sync.js +4 -0
  6. package/dist/resources/extensions/gsd/auto-worktree.js +9 -6
  7. package/dist/resources/extensions/gsd/auto.js +30 -3
  8. package/dist/resources/extensions/gsd/bootstrap/db-tools.js +156 -0
  9. package/dist/resources/extensions/gsd/bootstrap/system-context.js +46 -12
  10. package/dist/resources/extensions/gsd/commands/catalog.js +6 -1
  11. package/dist/resources/extensions/gsd/commands/handlers/core.js +1 -0
  12. package/dist/resources/extensions/gsd/commands/handlers/ops.js +5 -0
  13. package/dist/resources/extensions/gsd/commands-mcp-status.js +187 -0
  14. package/dist/resources/extensions/gsd/db-writer.js +34 -16
  15. package/dist/resources/extensions/gsd/doctor.js +8 -0
  16. package/dist/resources/extensions/gsd/git-service.js +8 -3
  17. package/dist/resources/extensions/gsd/gsd-db.js +12 -1
  18. package/dist/resources/extensions/gsd/markdown-renderer.js +1 -1
  19. package/dist/resources/extensions/gsd/preferences.js +9 -1
  20. package/dist/resources/extensions/gsd/prompts/complete-milestone.md +2 -4
  21. package/dist/resources/extensions/gsd/prompts/plan-slice.md +1 -1
  22. package/dist/resources/extensions/gsd/prompts/reassess-roadmap.md +6 -6
  23. package/dist/resources/extensions/gsd/prompts/replan-slice.md +3 -14
  24. package/dist/resources/extensions/gsd/prompts/validate-milestone.md +7 -37
  25. package/dist/resources/extensions/gsd/provider-error-pause.js +7 -0
  26. package/dist/resources/extensions/gsd/state.js +19 -2
  27. package/dist/resources/extensions/gsd/tools/plan-slice.js +1 -0
  28. package/dist/resources/extensions/gsd/tools/plan-task.js +1 -0
  29. package/dist/resources/extensions/gsd/tools/replan-slice.js +2 -0
  30. package/dist/resources/extensions/gsd/tools/validate-milestone.js +88 -0
  31. package/dist/resources/extensions/gsd/worktree-resolver.js +6 -0
  32. package/dist/resources/extensions/mcp-client/index.js +14 -0
  33. package/dist/web/standalone/.next/BUILD_ID +1 -1
  34. package/dist/web/standalone/.next/app-path-routes-manifest.json +16 -16
  35. package/dist/web/standalone/.next/build-manifest.json +2 -2
  36. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  37. package/dist/web/standalone/.next/server/app/_global-error.html +2 -2
  38. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  39. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  40. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  41. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  42. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  43. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  44. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  45. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  46. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  47. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  48. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  49. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  50. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  51. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  52. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  53. package/dist/web/standalone/.next/server/app/index.html +1 -1
  54. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  55. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  56. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  57. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  58. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  59. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  60. package/dist/web/standalone/.next/server/app-paths-manifest.json +16 -16
  61. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  62. package/dist/web/standalone/.next/server/pages/500.html +2 -2
  63. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  64. package/package.json +1 -1
  65. package/packages/pi-coding-agent/dist/core/auth-storage.d.ts +3 -1
  66. package/packages/pi-coding-agent/dist/core/auth-storage.d.ts.map +1 -1
  67. package/packages/pi-coding-agent/dist/core/auth-storage.js +15 -1
  68. package/packages/pi-coding-agent/dist/core/auth-storage.js.map +1 -1
  69. package/packages/pi-coding-agent/dist/core/local-model-check.d.ts +15 -0
  70. package/packages/pi-coding-agent/dist/core/local-model-check.d.ts.map +1 -0
  71. package/packages/pi-coding-agent/dist/core/local-model-check.js +41 -0
  72. package/packages/pi-coding-agent/dist/core/local-model-check.js.map +1 -0
  73. package/packages/pi-coding-agent/dist/core/model-registry.d.ts +11 -0
  74. package/packages/pi-coding-agent/dist/core/model-registry.d.ts.map +1 -1
  75. package/packages/pi-coding-agent/dist/core/model-registry.js +20 -1
  76. package/packages/pi-coding-agent/dist/core/model-registry.js.map +1 -1
  77. package/packages/pi-coding-agent/dist/core/settings-manager.d.ts +3 -0
  78. package/packages/pi-coding-agent/dist/core/settings-manager.d.ts.map +1 -1
  79. package/packages/pi-coding-agent/dist/core/settings-manager.js +6 -0
  80. package/packages/pi-coding-agent/dist/core/settings-manager.js.map +1 -1
  81. package/packages/pi-coding-agent/dist/main.d.ts.map +1 -1
  82. package/packages/pi-coding-agent/dist/main.js +17 -0
  83. package/packages/pi-coding-agent/dist/main.js.map +1 -1
  84. package/packages/pi-coding-agent/dist/modes/interactive/components/__tests__/timestamp.test.d.ts +2 -0
  85. package/packages/pi-coding-agent/dist/modes/interactive/components/__tests__/timestamp.test.d.ts.map +1 -0
  86. package/packages/pi-coding-agent/dist/modes/interactive/components/__tests__/timestamp.test.js +32 -0
  87. package/packages/pi-coding-agent/dist/modes/interactive/components/__tests__/timestamp.test.js.map +1 -0
  88. package/packages/pi-coding-agent/dist/modes/interactive/components/assistant-message.d.ts +3 -1
  89. package/packages/pi-coding-agent/dist/modes/interactive/components/assistant-message.d.ts.map +1 -1
  90. package/packages/pi-coding-agent/dist/modes/interactive/components/assistant-message.js +8 -1
  91. package/packages/pi-coding-agent/dist/modes/interactive/components/assistant-message.js.map +1 -1
  92. package/packages/pi-coding-agent/dist/modes/interactive/components/settings-selector.d.ts +2 -0
  93. package/packages/pi-coding-agent/dist/modes/interactive/components/settings-selector.d.ts.map +1 -1
  94. package/packages/pi-coding-agent/dist/modes/interactive/components/settings-selector.js +12 -0
  95. package/packages/pi-coding-agent/dist/modes/interactive/components/settings-selector.js.map +1 -1
  96. package/packages/pi-coding-agent/dist/modes/interactive/components/timestamp.d.ts +15 -0
  97. package/packages/pi-coding-agent/dist/modes/interactive/components/timestamp.d.ts.map +1 -0
  98. package/packages/pi-coding-agent/dist/modes/interactive/components/timestamp.js +40 -0
  99. package/packages/pi-coding-agent/dist/modes/interactive/components/timestamp.js.map +1 -0
  100. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
  101. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.js +4 -1
  102. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.js.map +1 -1
  103. package/packages/pi-coding-agent/dist/modes/interactive/components/user-message.d.ts +5 -2
  104. package/packages/pi-coding-agent/dist/modes/interactive/components/user-message.d.ts.map +1 -1
  105. package/packages/pi-coding-agent/dist/modes/interactive/components/user-message.js +13 -2
  106. package/packages/pi-coding-agent/dist/modes/interactive/components/user-message.js.map +1 -1
  107. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.d.ts.map +1 -1
  108. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.js +17 -8
  109. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.js.map +1 -1
  110. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  111. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +7 -3
  112. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  113. package/packages/pi-coding-agent/src/core/auth-storage.ts +15 -1
  114. package/packages/pi-coding-agent/src/core/local-model-check.ts +45 -0
  115. package/packages/pi-coding-agent/src/core/model-registry.ts +21 -1
  116. package/packages/pi-coding-agent/src/core/settings-manager.ts +9 -0
  117. package/packages/pi-coding-agent/src/main.ts +19 -0
  118. package/packages/pi-coding-agent/src/modes/interactive/components/__tests__/timestamp.test.ts +38 -0
  119. package/packages/pi-coding-agent/src/modes/interactive/components/assistant-message.ts +10 -0
  120. package/packages/pi-coding-agent/src/modes/interactive/components/settings-selector.ts +15 -0
  121. package/packages/pi-coding-agent/src/modes/interactive/components/timestamp.ts +48 -0
  122. package/packages/pi-coding-agent/src/modes/interactive/components/tool-execution.ts +3 -1
  123. package/packages/pi-coding-agent/src/modes/interactive/components/user-message.ts +18 -3
  124. package/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts +16 -7
  125. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +8 -1
  126. package/src/resources/extensions/gsd/auto/infra-errors.ts +3 -0
  127. package/src/resources/extensions/gsd/auto/phases.ts +45 -48
  128. package/src/resources/extensions/gsd/auto-prompts.ts +24 -1
  129. package/src/resources/extensions/gsd/auto-timers.ts +64 -3
  130. package/src/resources/extensions/gsd/auto-worktree-sync.ts +5 -0
  131. package/src/resources/extensions/gsd/auto-worktree.ts +9 -6
  132. package/src/resources/extensions/gsd/auto.ts +37 -3
  133. package/src/resources/extensions/gsd/bootstrap/db-tools.ts +148 -0
  134. package/src/resources/extensions/gsd/bootstrap/system-context.ts +48 -11
  135. package/src/resources/extensions/gsd/commands/catalog.ts +6 -1
  136. package/src/resources/extensions/gsd/commands/handlers/core.ts +1 -0
  137. package/src/resources/extensions/gsd/commands/handlers/ops.ts +5 -0
  138. package/src/resources/extensions/gsd/commands-mcp-status.ts +247 -0
  139. package/src/resources/extensions/gsd/db-writer.ts +39 -17
  140. package/src/resources/extensions/gsd/doctor.ts +7 -1
  141. package/src/resources/extensions/gsd/git-service.ts +6 -2
  142. package/src/resources/extensions/gsd/gsd-db.ts +16 -1
  143. package/src/resources/extensions/gsd/markdown-renderer.ts +1 -1
  144. package/src/resources/extensions/gsd/preferences.ts +11 -1
  145. package/src/resources/extensions/gsd/prompts/complete-milestone.md +2 -4
  146. package/src/resources/extensions/gsd/prompts/plan-slice.md +1 -1
  147. package/src/resources/extensions/gsd/prompts/reassess-roadmap.md +6 -6
  148. package/src/resources/extensions/gsd/prompts/replan-slice.md +3 -14
  149. package/src/resources/extensions/gsd/prompts/validate-milestone.md +7 -37
  150. package/src/resources/extensions/gsd/provider-error-pause.ts +9 -0
  151. package/src/resources/extensions/gsd/state.ts +19 -1
  152. package/src/resources/extensions/gsd/tests/auto-pr-bugs.test.ts +88 -0
  153. package/src/resources/extensions/gsd/tests/completed-units-metrics-sync.test.ts +114 -0
  154. package/src/resources/extensions/gsd/tests/db-writer.test.ts +79 -0
  155. package/src/resources/extensions/gsd/tests/derive-state-db.test.ts +60 -0
  156. package/src/resources/extensions/gsd/tests/est-annotation-timeout.test.ts +120 -0
  157. package/src/resources/extensions/gsd/tests/infra-error.test.ts +20 -2
  158. package/src/resources/extensions/gsd/tests/knowledge.test.ts +89 -0
  159. package/src/resources/extensions/gsd/tests/mcp-status.test.ts +103 -0
  160. package/src/resources/extensions/gsd/tests/merge-conflict-stops-loop.test.ts +66 -0
  161. package/src/resources/extensions/gsd/tests/preferences.test.ts +27 -0
  162. package/src/resources/extensions/gsd/tests/prompt-contracts.test.ts +11 -7
  163. package/src/resources/extensions/gsd/tests/stop-auto-merge-back.test.ts +67 -0
  164. package/src/resources/extensions/gsd/tests/terminated-transient.test.ts +49 -0
  165. package/src/resources/extensions/gsd/tests/tool-naming.test.ts +2 -1
  166. package/src/resources/extensions/gsd/tools/plan-slice.ts +2 -0
  167. package/src/resources/extensions/gsd/tools/plan-task.ts +2 -0
  168. package/src/resources/extensions/gsd/tools/replan-slice.ts +3 -0
  169. package/src/resources/extensions/gsd/tools/validate-milestone.ts +127 -0
  170. package/src/resources/extensions/gsd/worktree-resolver.ts +7 -0
  171. package/src/resources/extensions/mcp-client/index.ts +20 -0
  172. /package/dist/web/standalone/.next/static/{alS4hoANx0TK4UVZY27da → kxxAA66bah_yhPYqLBHE2}/_buildManifest.js +0 -0
  173. /package/dist/web/standalone/.next/static/{alS4hoANx0TK4UVZY27da → kxxAA66bah_yhPYqLBHE2}/_ssgManifest.js +0 -0
@@ -744,7 +744,21 @@ export class AuthStorage {
744
744
  * @param providerId - The provider to get an API key for
745
745
  * @param sessionId - Optional session ID for sticky credential selection
746
746
  */
747
- async getApiKey(providerId: string, sessionId?: string): Promise<string | undefined> {
747
+ async getApiKey(providerId: string, sessionId?: string, options?: { baseUrl?: string }): Promise<string | undefined> {
748
+ // If the model has a local baseUrl, return a dummy key to avoid auth blocking
749
+ if (options?.baseUrl) {
750
+ try {
751
+ const hostname = new URL(options.baseUrl).hostname;
752
+ if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "0.0.0.0" || hostname === "::1") {
753
+ return "local-no-key-needed";
754
+ }
755
+ } catch {
756
+ if (options.baseUrl.startsWith("unix:")) {
757
+ return "local-no-key-needed";
758
+ }
759
+ }
760
+ }
761
+
748
762
  // Runtime override takes highest priority
749
763
  const runtimeKey = this.runtimeOverrides.get(providerId);
750
764
  if (runtimeKey) {
@@ -0,0 +1,45 @@
1
+ /**
2
+ * local-model-check.ts — Utility to detect if a model baseUrl is local.
3
+ *
4
+ * Leaf module with zero transitive dependencies on TypeScript parameter properties.
5
+ * Used by ModelRegistry and tests.
6
+ */
7
+
8
+ /**
9
+ * Check if a model's baseUrl points to a local endpoint.
10
+ * Returns true for localhost, 127.0.0.1, 0.0.0.0, ::1, or unix socket paths.
11
+ * Returns false if baseUrl is empty (cloud provider) or points to a remote host.
12
+ */
13
+ export function isLocalModel(model: { baseUrl: string }): boolean {
14
+ const url = model.baseUrl;
15
+ if (!url) return false;
16
+
17
+ // Unix socket paths
18
+ if (url.startsWith("unix://") || url.startsWith("unix:")) return true;
19
+
20
+ try {
21
+ const parsed = new URL(url);
22
+ const hostname = parsed.hostname;
23
+ if (
24
+ hostname === "localhost" ||
25
+ hostname === "127.0.0.1" ||
26
+ hostname === "0.0.0.0" ||
27
+ hostname === "::1" ||
28
+ hostname === "[::1]"
29
+ ) {
30
+ return true;
31
+ }
32
+ } catch {
33
+ // If URL parsing fails, check raw string for local patterns
34
+ if (
35
+ url.includes("localhost") ||
36
+ url.includes("127.0.0.1") ||
37
+ url.includes("0.0.0.0") ||
38
+ url.includes("[::1]")
39
+ ) {
40
+ return true;
41
+ }
42
+ }
43
+
44
+ return false;
45
+ }
@@ -28,6 +28,7 @@ import { ModelDiscoveryCache } from "./discovery-cache.js";
28
28
  import type { DiscoveredModel, DiscoveryResult } from "./model-discovery.js";
29
29
  import { getDefaultTTL, getDiscoverableProviders, getDiscoveryAdapter } from "./model-discovery.js";
30
30
  import { clearConfigValueCache, resolveConfigValue, resolveHeaders } from "./resolve-config-value.js";
31
+ import { isLocalModel } from "./local-model-check.js";
31
32
 
32
33
  const Ajv = (AjvModule as any).default || AjvModule;
33
34
  const ajv = new Ajv();
@@ -557,7 +558,7 @@ export class ModelRegistry {
557
558
  async getApiKey(model: Model<Api>, sessionId?: string): Promise<string | undefined> {
558
559
  const authMode = this.getProviderAuthMode(model.provider);
559
560
  if (authMode === "externalCli" || authMode === "none") return undefined;
560
- return this.authStorage.getApiKey(model.provider, sessionId);
561
+ return this.authStorage.getApiKey(model.provider, sessionId, { baseUrl: model.baseUrl });
561
562
  }
562
563
 
563
564
  /**
@@ -807,6 +808,25 @@ export class ModelRegistry {
807
808
  }
808
809
  return converted;
809
810
  }
811
+
812
+ /**
813
+ * Check if a model's baseUrl points to a local endpoint.
814
+ * Delegates to standalone isLocalModel() function.
815
+ */
816
+ static isLocalModel(model: Model<Api>): boolean {
817
+ return isLocalModel(model);
818
+ }
819
+
820
+ /**
821
+ * Check if all models in the registry are local.
822
+ * Returns true only if every model passes isLocalModel().
823
+ * Returns false if there are no models.
824
+ */
825
+ isAllLocalChain(): boolean {
826
+ const models = this.getAll();
827
+ if (models.length === 0) return false;
828
+ return models.every((m) => isLocalModel(m));
829
+ }
810
830
  }
811
831
 
812
832
  /**
@@ -151,6 +151,7 @@ export interface Settings {
151
151
  fallback?: FallbackSettings;
152
152
  modelDiscovery?: ModelDiscoverySettings;
153
153
  editMode?: "standard" | "hashline"; // Edit tool mode: "standard" (text match) or "hashline" (LINE#ID anchors). Default: "standard"
154
+ timestampFormat?: "date-time-iso" | "date-time-us"; // Timestamp display format for messages. Default: "date-time-iso"
154
155
  }
155
156
 
156
157
  /** Deep merge settings: project/overrides take precedence, nested objects merge recursively */
@@ -1087,4 +1088,12 @@ export class SettingsManager {
1087
1088
  setEditMode(mode: "standard" | "hashline"): void {
1088
1089
  this.setGlobalSetting("editMode", mode);
1089
1090
  }
1091
+
1092
+ getTimestampFormat(): "date-time-iso" | "date-time-us" {
1093
+ return this.settings.timestampFormat ?? "date-time-iso";
1094
+ }
1095
+
1096
+ setTimestampFormat(format: "date-time-iso" | "date-time-us"): void {
1097
+ this.setGlobalSetting("timestampFormat", format);
1098
+ }
1090
1099
  }
@@ -391,6 +391,25 @@ export async function main(args: string[]) {
391
391
  const authStorage = AuthStorage.create();
392
392
  const modelRegistry = new ModelRegistry(authStorage, getModelsPath());
393
393
 
394
+ // Offline mode validation / auto-detection
395
+ if (offlineMode) {
396
+ // --offline flag: validate all models are local
397
+ if (!modelRegistry.isAllLocalChain()) {
398
+ const remoteModel = modelRegistry.getAll().find((m) => !ModelRegistry.isLocalModel(m));
399
+ if (remoteModel) {
400
+ console.error(
401
+ `Error: --offline requires all configured models to be local. Found remote model: ${remoteModel.name} (${remoteModel.baseUrl || "cloud API"})`,
402
+ );
403
+ process.exit(1);
404
+ }
405
+ }
406
+ } else if (modelRegistry.isAllLocalChain() && modelRegistry.getAll().length > 0) {
407
+ // Auto-detect: all models are local, enable offline mode
408
+ process.env.PI_OFFLINE = "1";
409
+ process.env.PI_SKIP_VERSION_CHECK = "1";
410
+ console.log("[gsd] All configured models are local \u2014 enabling offline mode automatically.");
411
+ }
412
+
394
413
  const resourceLoader = new DefaultResourceLoader({
395
414
  cwd,
396
415
  agentDir,
@@ -0,0 +1,38 @@
1
+ import { test, describe } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { formatTimestamp } from "../timestamp.js";
4
+
5
+ describe("formatTimestamp", () => {
6
+ // Use a fixed local timestamp to avoid timezone issues
7
+ const d = new Date(2026, 2, 24, 10, 34, 0); // Mar 24, 2026 10:34:00 local time
8
+ const ts = d.getTime();
9
+
10
+ test("date-time-iso format (default)", () => {
11
+ assert.equal(formatTimestamp(ts, "date-time-iso"), "2026-03-24 10:34");
12
+ assert.equal(formatTimestamp(ts), "2026-03-24 10:34"); // default
13
+ });
14
+
15
+ test("date-time-us format", () => {
16
+ assert.equal(formatTimestamp(ts, "date-time-us"), "03-24-2026 10:34 AM");
17
+ });
18
+
19
+ test("US format handles PM correctly", () => {
20
+ const pm = new Date(2026, 2, 24, 14, 5, 0).getTime();
21
+ assert.equal(formatTimestamp(pm, "date-time-us"), "03-24-2026 2:05 PM");
22
+ });
23
+
24
+ test("US format handles noon as 12 PM", () => {
25
+ const noon = new Date(2026, 2, 24, 12, 0, 0).getTime();
26
+ assert.equal(formatTimestamp(noon, "date-time-us"), "03-24-2026 12:00 PM");
27
+ });
28
+
29
+ test("US format handles midnight as 12 AM", () => {
30
+ const midnight = new Date(2026, 2, 24, 0, 0, 0).getTime();
31
+ assert.equal(formatTimestamp(midnight, "date-time-us"), "03-24-2026 12:00 AM");
32
+ });
33
+
34
+ test("ISO format pads single digit months and days", () => {
35
+ const jan1 = new Date(2026, 0, 1, 9, 5, 0).getTime();
36
+ assert.equal(formatTimestamp(jan1, "date-time-iso"), "2026-01-01 09:05");
37
+ });
38
+ });
@@ -1,6 +1,7 @@
1
1
  import type { AssistantMessage } from "@gsd/pi-ai";
2
2
  import { Container, Markdown, type MarkdownTheme, Spacer, Text } from "@gsd/pi-tui";
3
3
  import { getMarkdownTheme, theme } from "../theme/theme.js";
4
+ import { formatTimestamp, type TimestampFormat } from "./timestamp.js";
4
5
 
5
6
  /**
6
7
  * Component that renders a complete assistant message
@@ -10,16 +11,19 @@ export class AssistantMessageComponent extends Container {
10
11
  private hideThinkingBlock: boolean;
11
12
  private markdownTheme: MarkdownTheme;
12
13
  private lastMessage?: AssistantMessage;
14
+ private timestampFormat: TimestampFormat;
13
15
 
14
16
  constructor(
15
17
  message?: AssistantMessage,
16
18
  hideThinkingBlock = false,
17
19
  markdownTheme: MarkdownTheme = getMarkdownTheme(),
20
+ timestampFormat: TimestampFormat = "date-time-iso",
18
21
  ) {
19
22
  super();
20
23
 
21
24
  this.hideThinkingBlock = hideThinkingBlock;
22
25
  this.markdownTheme = markdownTheme;
26
+ this.timestampFormat = timestampFormat;
23
27
 
24
28
  // Container for text/thinking content
25
29
  this.contentContainer = new Container();
@@ -111,5 +115,11 @@ export class AssistantMessageComponent extends Container {
111
115
  this.contentContainer.addChild(new Text(theme.fg("error", `Error: ${errorMsg}`), 1, 0));
112
116
  }
113
117
  }
118
+
119
+ // Show timestamp when the message is complete (has a stop reason)
120
+ if (message.stopReason && message.timestamp) {
121
+ const timeStr = formatTimestamp(message.timestamp, this.timestampFormat);
122
+ this.contentContainer.addChild(new Text(theme.fg("dim", timeStr), 1, 0));
123
+ }
114
124
  }
115
125
  }
@@ -45,6 +45,7 @@ export interface SettingsConfig {
45
45
  respectGitignoreInPicker: boolean;
46
46
  quietStartup: boolean;
47
47
  clearOnShrink: boolean;
48
+ timestampFormat: "date-time-iso" | "date-time-us";
48
49
  }
49
50
 
50
51
  export interface SettingsCallbacks {
@@ -69,6 +70,7 @@ export interface SettingsCallbacks {
69
70
  onRespectGitignoreInPickerChange: (enabled: boolean) => void;
70
71
  onQuietStartupChange: (enabled: boolean) => void;
71
72
  onClearOnShrinkChange: (enabled: boolean) => void;
73
+ onTimestampFormatChange: (format: "date-time-iso" | "date-time-us") => void;
72
74
  onCancel: () => void;
73
75
  }
74
76
 
@@ -355,6 +357,16 @@ export class SettingsSelectorComponent extends Container {
355
357
  values: ["true", "false"],
356
358
  });
357
359
 
360
+ // Timestamp format (insert after respect-gitignore-in-picker)
361
+ const gitignoreIndex = items.findIndex((item) => item.id === "respect-gitignore-in-picker");
362
+ items.splice(gitignoreIndex + 1, 0, {
363
+ id: "timestamp-format",
364
+ label: "Timestamp format",
365
+ description: "Date/time format for message timestamps",
366
+ currentValue: config.timestampFormat,
367
+ values: ["date-time-iso", "date-time-us"],
368
+ });
369
+
358
370
  // Add borders
359
371
  this.addChild(new DynamicBorder());
360
372
 
@@ -420,6 +432,9 @@ export class SettingsSelectorComponent extends Container {
420
432
  case "respect-gitignore-in-picker":
421
433
  callbacks.onRespectGitignoreInPickerChange(newValue === "true");
422
434
  break;
435
+ case "timestamp-format":
436
+ callbacks.onTimestampFormatChange(newValue as "date-time-iso" | "date-time-us");
437
+ break;
423
438
  }
424
439
  },
425
440
  callbacks.onCancel,
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Timestamp formatting for message display.
3
+ *
4
+ * Formats:
5
+ * - "time-date-iso": 10:34 2025-03-24 (default)
6
+ * - "date-time-iso": 2025-03-24 10:34
7
+ * - "time-date-us": 10:34 AM 03/24/2025
8
+ * - "date-time-us": 03/24/2025 10:34 AM
9
+ */
10
+
11
+ export type TimestampFormat = "date-time-iso" | "date-time-us";
12
+
13
+ function pad2(n: number): string {
14
+ return n.toString().padStart(2, "0");
15
+ }
16
+
17
+ function isoDate(d: Date): string {
18
+ return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
19
+ }
20
+
21
+ function isoTime(d: Date): string {
22
+ return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
23
+ }
24
+
25
+ function usDate(d: Date): string {
26
+ return `${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}-${d.getFullYear()}`;
27
+ }
28
+
29
+ function usTime(d: Date): string {
30
+ const hours = d.getHours();
31
+ const period = hours >= 12 ? "PM" : "AM";
32
+ const h = hours % 12 || 12;
33
+ return `${h}:${pad2(d.getMinutes())} ${period}`;
34
+ }
35
+
36
+ /**
37
+ * Format a timestamp for message display using the specified format.
38
+ */
39
+ export function formatTimestamp(timestamp: number, format: TimestampFormat = "date-time-iso"): string {
40
+ const d = new Date(timestamp);
41
+
42
+ switch (format) {
43
+ case "date-time-iso":
44
+ return `${isoDate(d)} ${isoTime(d)}`;
45
+ case "date-time-us":
46
+ return `${usDate(d)} ${usTime(d)}`;
47
+ }
48
+ }
@@ -895,7 +895,9 @@ export class ToolExecutionComponent extends Container {
895
895
  // Server-side Anthropic web search
896
896
  text = theme.fg("toolTitle", theme.bold("web search"));
897
897
 
898
- if (this.result) {
898
+ if (process.env.PI_OFFLINE === "1") {
899
+ text += "\n\n" + theme.fg("muted", "\u{1F50C} Offline \u{2014} web search unavailable");
900
+ } else if (this.result) {
899
901
  const output = this.getTextOutput().trim();
900
902
  if (output) {
901
903
  const lines = output.split("\n");
@@ -1,15 +1,21 @@
1
- import { Container, Markdown, type MarkdownTheme, Spacer } from "@gsd/pi-tui";
1
+ import { Container, Markdown, type MarkdownTheme, Spacer, Text } from "@gsd/pi-tui";
2
2
  import { getMarkdownTheme, theme } from "../theme/theme.js";
3
+ import { formatTimestamp, type TimestampFormat } from "./timestamp.js";
3
4
 
4
5
  const OSC133_ZONE_START = "\x1b]133;A\x07";
5
6
  const OSC133_ZONE_END = "\x1b]133;B\x07";
6
7
 
7
8
  /**
8
- * Component that renders a user message
9
+ * Component that renders a user message with a right-aligned timestamp.
9
10
  */
10
11
  export class UserMessageComponent extends Container {
11
- constructor(text: string, markdownTheme: MarkdownTheme = getMarkdownTheme()) {
12
+ private timestamp: number | undefined;
13
+ private timestampFormat: TimestampFormat;
14
+
15
+ constructor(text: string, markdownTheme: MarkdownTheme = getMarkdownTheme(), timestamp?: number, timestampFormat: TimestampFormat = "date-time-iso") {
12
16
  super();
17
+ this.timestamp = timestamp;
18
+ this.timestampFormat = timestampFormat;
13
19
  this.addChild(new Spacer(1));
14
20
  this.addChild(
15
21
  new Markdown(text, 1, 1, markdownTheme, {
@@ -25,6 +31,15 @@ export class UserMessageComponent extends Container {
25
31
  return lines;
26
32
  }
27
33
 
34
+ // Insert right-aligned timestamp above the message content
35
+ if (this.timestamp) {
36
+ const timeStr = formatTimestamp(this.timestamp, this.timestampFormat);
37
+ const label = theme.fg("dim", timeStr);
38
+ const padding = Math.max(0, width - timeStr.length - 1);
39
+ const timestampLine = " ".repeat(padding) + label;
40
+ lines.splice(0, 0, timestampLine);
41
+ }
42
+
28
43
  lines[0] = OSC133_ZONE_START + lines[0];
29
44
  lines[lines.length - 1] = lines[lines.length - 1] + OSC133_ZONE_END;
30
45
  return lines;
@@ -100,6 +100,7 @@ export async function handleAgentEvent(host: InteractiveModeStateHost & {
100
100
  undefined,
101
101
  host.hideThinkingBlock,
102
102
  host.getMarkdownThemeWithSettings(),
103
+ host.settingsManager.getTimestampFormat(),
103
104
  );
104
105
  host.streamingMessage = event.message;
105
106
  host.chatContainer.addChild(host.streamingComponent);
@@ -144,13 +145,21 @@ export async function handleAgentEvent(host: InteractiveModeStateHost & {
144
145
  } else if (content.type === "webSearchResult") {
145
146
  const component = host.pendingTools.get(content.toolUseId);
146
147
  if (component) {
147
- const searchContent = content.content;
148
- const isError = searchContent && typeof searchContent === "object" && "type" in (searchContent as any) && (searchContent as any).type === "web_search_tool_result_error";
149
- component.updateResult({
150
- content: [{ type: "text", text: host.formatWebSearchResult(searchContent) }],
151
- isError: !!isError,
152
- });
153
- host.pendingTools.delete(content.toolUseId);
148
+ if (process.env.PI_OFFLINE === "1") {
149
+ component.updateResult({
150
+ content: [{ type: "text", text: "Web search disabled (offline mode)" }],
151
+ isError: false,
152
+ });
153
+ host.pendingTools.delete(content.toolUseId);
154
+ } else {
155
+ const searchContent = content.content;
156
+ const isError = searchContent && typeof searchContent === "object" && "type" in (searchContent as any) && (searchContent as any).type === "web_search_tool_result_error";
157
+ component.updateResult({
158
+ content: [{ type: "text", text: host.formatWebSearchResult(searchContent) }],
159
+ isError: !!isError,
160
+ });
161
+ host.pendingTools.delete(content.toolUseId);
162
+ }
154
163
  }
155
164
  }
156
165
  }
@@ -2099,11 +2099,13 @@ export class InteractiveMode {
2099
2099
  const userComponent = new UserMessageComponent(
2100
2100
  skillBlock.userMessage,
2101
2101
  this.getMarkdownThemeWithSettings(),
2102
+ message.timestamp,
2103
+ this.settingsManager.getTimestampFormat(),
2102
2104
  );
2103
2105
  this.chatContainer.addChild(userComponent);
2104
2106
  }
2105
2107
  } else {
2106
- const userComponent = new UserMessageComponent(textContent, this.getMarkdownThemeWithSettings());
2108
+ const userComponent = new UserMessageComponent(textContent, this.getMarkdownThemeWithSettings(), message.timestamp, this.settingsManager.getTimestampFormat());
2107
2109
  this.chatContainer.addChild(userComponent);
2108
2110
  }
2109
2111
  if (options?.populateHistory) {
@@ -2117,6 +2119,7 @@ export class InteractiveMode {
2117
2119
  message,
2118
2120
  this.hideThinkingBlock,
2119
2121
  this.getMarkdownThemeWithSettings(),
2122
+ this.settingsManager.getTimestampFormat(),
2120
2123
  );
2121
2124
  this.chatContainer.addChild(assistantComponent);
2122
2125
  break;
@@ -2795,6 +2798,7 @@ export class InteractiveMode {
2795
2798
  respectGitignoreInPicker: this.settingsManager.getRespectGitignoreInPicker(),
2796
2799
  quietStartup: this.settingsManager.getQuietStartup(),
2797
2800
  clearOnShrink: this.settingsManager.getClearOnShrink(),
2801
+ timestampFormat: this.settingsManager.getTimestampFormat(),
2798
2802
  },
2799
2803
  {
2800
2804
  onAutoCompactChange: (enabled) => {
@@ -2898,6 +2902,9 @@ export class InteractiveMode {
2898
2902
  this.settingsManager.setRespectGitignoreInPicker(enabled);
2899
2903
  this.autocompleteProvider?.setRespectGitignore(enabled);
2900
2904
  },
2905
+ onTimestampFormatChange: (format) => {
2906
+ this.settingsManager.setTimestampFormat(format);
2907
+ },
2901
2908
  onCancel: () => {
2902
2909
  done();
2903
2910
  this.ui.requestRender();
@@ -18,6 +18,9 @@ export const INFRA_ERROR_CODES: ReadonlySet<string> = new Set([
18
18
  "EDQUOT", // disk quota exceeded
19
19
  "EMFILE", // too many open files (process)
20
20
  "ENFILE", // too many open files (system)
21
+ "ECONNREFUSED", // connection refused (offline / local server down)
22
+ "ENOTFOUND", // DNS lookup failed (offline / no network)
23
+ "ENETUNREACH", // network unreachable (offline / no route)
21
24
  ]);
22
25
 
23
26
  /**
@@ -27,7 +27,9 @@ import { debugLog } from "../debug-logger.js";
27
27
  import { gsdRoot } from "../paths.js";
28
28
  import { atomicWriteSync } from "../atomic-write.js";
29
29
  import { PROJECT_FILES } from "../detection.js";
30
+ import { MergeConflictError } from "../git-service.js";
30
31
  import { join } from "node:path";
32
+ import { existsSync, cpSync } from "node:fs";
31
33
 
32
34
  // ─── generateMilestoneReport ──────────────────────────────────────────────────
33
35
 
@@ -233,26 +235,23 @@ export async function runPreDispatch(
233
235
  loopState.stuckRecoveryAttempts = 0;
234
236
 
235
237
  // Worktree lifecycle on milestone transition — merge current, enter next
236
- deps.resolver.mergeAndExit(s.currentMilestoneId!, ctx.ui);
237
-
238
- // Opt-in: create draft PR on milestone completion
239
- if (prefs?.git?.auto_pr) {
240
- try {
241
- const { createDraftPR } = await import("../git-service.js");
242
- const prUrl = createDraftPR(
243
- s.basePath,
244
- s.currentMilestoneId!,
245
- `[GSD] ${s.currentMilestoneId} complete`,
246
- `Milestone ${s.currentMilestoneId} completed by GSD auto-mode.\n\nSee .gsd/${s.currentMilestoneId}/ for details.`,
238
+ try {
239
+ deps.resolver.mergeAndExit(s.currentMilestoneId!, ctx.ui);
240
+ } catch (mergeErr) {
241
+ if (mergeErr instanceof MergeConflictError) {
242
+ // Real code conflicts — stop the loop instead of retrying forever (#2330)
243
+ ctx.ui.notify(
244
+ `Merge conflict: ${mergeErr.conflictedFiles.join(", ")}. Resolve conflicts manually and run /gsd auto to resume.`,
245
+ "error",
247
246
  );
248
- if (prUrl) {
249
- ctx.ui.notify(`Draft PR created: ${prUrl}`, "info");
250
- }
251
- } catch {
252
- // Non-fatal — PR creation is best-effort
247
+ await deps.stopAuto(ctx, pi, `Merge conflict on milestone ${s.currentMilestoneId}`);
248
+ return { action: "break", reason: "merge-conflict" };
253
249
  }
250
+ // Non-conflict errors — log and continue
254
251
  }
255
252
 
253
+ // PR creation (auto_pr) is handled inside mergeMilestoneToMain (#2302)
254
+
256
255
  deps.invalidateAllCaches();
257
256
 
258
257
  state = await deps.deriveState(s.basePath);
@@ -279,9 +278,17 @@ export async function runPreDispatch(
279
278
  // Reset completed-units tracking for the new milestone — stale entries
280
279
  // from the previous milestone cause the dispatch loop to skip units
281
280
  // that haven't actually been completed in the new milestone's context.
281
+ // Archive the old completed-units.json instead of wiping it (#2313).
282
282
  s.completedUnits = [];
283
283
  try {
284
284
  const completedKeysPath = join(gsdRoot(s.basePath), "completed-units.json");
285
+ if (existsSync(completedKeysPath) && s.currentMilestoneId) {
286
+ const archivePath = join(
287
+ gsdRoot(s.basePath),
288
+ `completed-units-${s.currentMilestoneId}.json`,
289
+ );
290
+ cpSync(completedKeysPath, archivePath);
291
+ }
285
292
  atomicWriteSync(completedKeysPath, JSON.stringify([], null, 2));
286
293
  } catch { /* non-fatal */ }
287
294
 
@@ -322,25 +329,20 @@ export async function runPreDispatch(
322
329
  if (incomplete.length === 0 && state.registry.length > 0) {
323
330
  // All milestones complete — merge milestone branch before stopping
324
331
  if (s.currentMilestoneId) {
325
- deps.resolver.mergeAndExit(s.currentMilestoneId, ctx.ui);
326
-
327
- // Opt-in: create draft PR on milestone completion
328
- if (prefs?.git?.auto_pr) {
329
- try {
330
- const { createDraftPR } = await import("../git-service.js");
331
- const prUrl = createDraftPR(
332
- s.basePath,
333
- s.currentMilestoneId,
334
- `[GSD] ${s.currentMilestoneId} complete`,
335
- `Milestone ${s.currentMilestoneId} completed by GSD auto-mode.\n\nSee .gsd/${s.currentMilestoneId}/ for details.`,
332
+ try {
333
+ deps.resolver.mergeAndExit(s.currentMilestoneId, ctx.ui);
334
+ } catch (mergeErr) {
335
+ if (mergeErr instanceof MergeConflictError) {
336
+ ctx.ui.notify(
337
+ `Merge conflict: ${mergeErr.conflictedFiles.join(", ")}. Resolve conflicts manually and run /gsd auto to resume.`,
338
+ "error",
336
339
  );
337
- if (prUrl) {
338
- ctx.ui.notify(`Draft PR created: ${prUrl}`, "info");
339
- }
340
- } catch {
341
- // Non-fatal — PR creation is best-effort
340
+ await deps.stopAuto(ctx, pi, `Merge conflict on milestone ${s.currentMilestoneId}`);
341
+ return { action: "break", reason: "merge-conflict" };
342
342
  }
343
343
  }
344
+
345
+ // PR creation (auto_pr) is handled inside mergeMilestoneToMain (#2302)
344
346
  }
345
347
  deps.sendDesktopNotification(
346
348
  "GSD",
@@ -422,25 +424,20 @@ export async function runPreDispatch(
422
424
  if (state.phase === "complete") {
423
425
  // Milestone merge on complete (before closeout so branch state is clean)
424
426
  if (s.currentMilestoneId) {
425
- deps.resolver.mergeAndExit(s.currentMilestoneId, ctx.ui);
426
-
427
- // Opt-in: create draft PR on milestone completion
428
- if (prefs?.git?.auto_pr) {
429
- try {
430
- const { createDraftPR } = await import("../git-service.js");
431
- const prUrl = createDraftPR(
432
- s.basePath,
433
- s.currentMilestoneId,
434
- `[GSD] ${s.currentMilestoneId} complete`,
435
- `Milestone ${s.currentMilestoneId} completed by GSD auto-mode.\n\nSee .gsd/${s.currentMilestoneId}/ for details.`,
427
+ try {
428
+ deps.resolver.mergeAndExit(s.currentMilestoneId, ctx.ui);
429
+ } catch (mergeErr) {
430
+ if (mergeErr instanceof MergeConflictError) {
431
+ ctx.ui.notify(
432
+ `Merge conflict: ${mergeErr.conflictedFiles.join(", ")}. Resolve conflicts manually and run /gsd auto to resume.`,
433
+ "error",
436
434
  );
437
- if (prUrl) {
438
- ctx.ui.notify(`Draft PR created: ${prUrl}`, "info");
439
- }
440
- } catch {
441
- // Non-fatal — PR creation is best-effort
435
+ await deps.stopAuto(ctx, pi, `Merge conflict on milestone ${s.currentMilestoneId}`);
436
+ return { action: "break", reason: "merge-conflict" };
442
437
  }
443
438
  }
439
+
440
+ // PR creation (auto_pr) is handled inside mergeMilestoneToMain (#2302)
444
441
  }
445
442
  deps.sendDesktopNotification(
446
443
  "GSD",