mstro-app 0.4.51 → 0.5.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 +10 -5
- package/bin/mstro.js +1 -1
- package/dist/server/cli/headless/claude-invoker-stall.d.ts.map +1 -1
- package/dist/server/cli/headless/claude-invoker-stall.js +7 -2
- package/dist/server/cli/headless/claude-invoker-stall.js.map +1 -1
- package/dist/server/cli/headless/claude-invoker.js +1 -1
- package/dist/server/cli/headless/claude-invoker.js.map +1 -1
- package/dist/server/cli/headless/runner.d.ts.map +1 -1
- package/dist/server/cli/headless/runner.js +63 -67
- package/dist/server/cli/headless/runner.js.map +1 -1
- package/dist/server/cli/headless/stall-assessor.d.ts.map +1 -1
- package/dist/server/cli/headless/stall-assessor.js +9 -4
- package/dist/server/cli/headless/stall-assessor.js.map +1 -1
- package/dist/server/cli/improvisation-history-store.d.ts +16 -0
- package/dist/server/cli/improvisation-history-store.d.ts.map +1 -0
- package/dist/server/cli/improvisation-history-store.js +52 -0
- package/dist/server/cli/improvisation-history-store.js.map +1 -0
- package/dist/server/cli/improvisation-movements.d.ts +31 -0
- package/dist/server/cli/improvisation-movements.d.ts.map +1 -0
- package/dist/server/cli/improvisation-movements.js +93 -0
- package/dist/server/cli/improvisation-movements.js.map +1 -0
- package/dist/server/cli/improvisation-output-queue.d.ts +13 -0
- package/dist/server/cli/improvisation-output-queue.d.ts.map +1 -0
- package/dist/server/cli/improvisation-output-queue.js +40 -0
- package/dist/server/cli/improvisation-output-queue.js.map +1 -0
- package/dist/server/cli/improvisation-retry.d.ts +21 -51
- package/dist/server/cli/improvisation-retry.d.ts.map +1 -1
- package/dist/server/cli/improvisation-retry.js +18 -433
- package/dist/server/cli/improvisation-retry.js.map +1 -1
- package/dist/server/cli/improvisation-session-manager.d.ts +10 -8
- package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
- package/dist/server/cli/improvisation-session-manager.js +53 -148
- package/dist/server/cli/improvisation-session-manager.js.map +1 -1
- package/dist/server/cli/retry/retry-best-result.d.ts +4 -0
- package/dist/server/cli/retry/retry-best-result.d.ts.map +1 -0
- package/dist/server/cli/retry/retry-best-result.js +61 -0
- package/dist/server/cli/retry/retry-best-result.js.map +1 -0
- package/dist/server/cli/retry/retry-context-loss.d.ts +6 -0
- package/dist/server/cli/retry/retry-context-loss.d.ts.map +1 -0
- package/dist/server/cli/retry/retry-context-loss.js +68 -0
- package/dist/server/cli/retry/retry-context-loss.js.map +1 -0
- package/dist/server/cli/retry/retry-premature-completion.d.ts +5 -0
- package/dist/server/cli/retry/retry-premature-completion.d.ts.map +1 -0
- package/dist/server/cli/retry/retry-premature-completion.js +81 -0
- package/dist/server/cli/retry/retry-premature-completion.js.map +1 -0
- package/dist/server/cli/retry/retry-recovery-strategies.d.ts +13 -0
- package/dist/server/cli/retry/retry-recovery-strategies.d.ts.map +1 -0
- package/dist/server/cli/retry/retry-recovery-strategies.js +166 -0
- package/dist/server/cli/retry/retry-recovery-strategies.js.map +1 -0
- package/dist/server/cli/retry/retry-resume-strategy.d.ts +12 -0
- package/dist/server/cli/retry/retry-resume-strategy.d.ts.map +1 -0
- package/dist/server/cli/retry/retry-resume-strategy.js +22 -0
- package/dist/server/cli/retry/retry-resume-strategy.js.map +1 -0
- package/dist/server/cli/retry/retry-runner-factory.d.ts +11 -0
- package/dist/server/cli/retry/retry-runner-factory.d.ts.map +1 -0
- package/dist/server/cli/retry/retry-runner-factory.js +60 -0
- package/dist/server/cli/retry/retry-runner-factory.js.map +1 -0
- package/dist/server/cli/retry/retry-tool-results.d.ts +9 -0
- package/dist/server/cli/retry/retry-tool-results.d.ts.map +1 -0
- package/dist/server/cli/retry/retry-tool-results.js +24 -0
- package/dist/server/cli/retry/retry-tool-results.js.map +1 -0
- package/dist/server/cli/retry/retry-types.d.ts +30 -0
- package/dist/server/cli/retry/retry-types.d.ts.map +1 -0
- package/dist/server/cli/retry/retry-types.js +4 -0
- package/dist/server/cli/retry/retry-types.js.map +1 -0
- package/dist/server/index.js +21 -109
- package/dist/server/index.js.map +1 -1
- package/dist/server/server-setup.d.ts +16 -1
- package/dist/server/server-setup.d.ts.map +1 -1
- package/dist/server/server-setup.js +107 -0
- package/dist/server/server-setup.js.map +1 -1
- package/dist/server/services/plan/board-config.d.ts +21 -0
- package/dist/server/services/plan/board-config.d.ts.map +1 -0
- package/dist/server/services/plan/board-config.js +112 -0
- package/dist/server/services/plan/board-config.js.map +1 -0
- package/dist/server/services/plan/composer.d.ts +1 -1
- package/dist/server/services/plan/composer.d.ts.map +1 -1
- package/dist/server/services/plan/composer.js +7 -5
- package/dist/server/services/plan/composer.js.map +1 -1
- package/dist/server/services/plan/executor.d.ts +48 -48
- package/dist/server/services/plan/executor.d.ts.map +1 -1
- package/dist/server/services/plan/executor.js +157 -455
- package/dist/server/services/plan/executor.js.map +1 -1
- package/dist/server/services/plan/issue-loader.d.ts +16 -0
- package/dist/server/services/plan/issue-loader.d.ts.map +1 -0
- package/dist/server/services/plan/issue-loader.js +46 -0
- package/dist/server/services/plan/issue-loader.js.map +1 -0
- package/dist/server/services/plan/issue-writer.d.ts +34 -0
- package/dist/server/services/plan/issue-writer.d.ts.map +1 -0
- package/dist/server/services/plan/issue-writer.js +110 -0
- package/dist/server/services/plan/issue-writer.js.map +1 -0
- package/dist/server/services/plan/output-manager.d.ts.map +1 -1
- package/dist/server/services/plan/output-manager.js +2 -1
- package/dist/server/services/plan/output-manager.js.map +1 -1
- package/dist/server/services/plan/progress-log.d.ts +11 -0
- package/dist/server/services/plan/progress-log.d.ts.map +1 -0
- package/dist/server/services/plan/progress-log.js +81 -0
- package/dist/server/services/plan/progress-log.js.map +1 -0
- package/dist/server/services/plan/prompt-builder.d.ts.map +1 -1
- package/dist/server/services/plan/prompt-builder.js +48 -31
- package/dist/server/services/plan/prompt-builder.js.map +1 -1
- package/dist/server/services/plan/readiness-planner.d.ts +15 -0
- package/dist/server/services/plan/readiness-planner.d.ts.map +1 -0
- package/dist/server/services/plan/readiness-planner.js +41 -0
- package/dist/server/services/plan/readiness-planner.js.map +1 -0
- package/dist/server/services/plan/review-gate.d.ts +31 -0
- package/dist/server/services/plan/review-gate.d.ts.map +1 -1
- package/dist/server/services/plan/review-gate.js +52 -2
- package/dist/server/services/plan/review-gate.js.map +1 -1
- package/dist/server/services/platform.d.ts +56 -0
- package/dist/server/services/platform.d.ts.map +1 -1
- package/dist/server/services/platform.js +154 -52
- package/dist/server/services/platform.js.map +1 -1
- package/dist/server/services/websocket/file-download-handler.d.ts +17 -0
- package/dist/server/services/websocket/file-download-handler.d.ts.map +1 -0
- package/dist/server/services/websocket/file-download-handler.js +165 -0
- package/dist/server/services/websocket/file-download-handler.js.map +1 -0
- package/dist/server/services/websocket/git-branch-handlers.d.ts +1 -1
- package/dist/server/services/websocket/git-branch-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/git-branch-handlers.js +21 -1
- package/dist/server/services/websocket/git-branch-handlers.js.map +1 -1
- package/dist/server/services/websocket/git-handlers.js +1 -1
- package/dist/server/services/websocket/git-handlers.js.map +1 -1
- package/dist/server/services/websocket/git-worktree-handlers.d.ts +2 -0
- package/dist/server/services/websocket/git-worktree-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/git-worktree-handlers.js +30 -4
- package/dist/server/services/websocket/git-worktree-handlers.js.map +1 -1
- package/dist/server/services/websocket/handler-context.d.ts +15 -0
- package/dist/server/services/websocket/handler-context.d.ts.map +1 -1
- package/dist/server/services/websocket/handler.d.ts +7 -0
- package/dist/server/services/websocket/handler.d.ts.map +1 -1
- package/dist/server/services/websocket/handler.js +73 -11
- package/dist/server/services/websocket/handler.js.map +1 -1
- package/dist/server/services/websocket/msg-id-tracker.d.ts +21 -0
- package/dist/server/services/websocket/msg-id-tracker.d.ts.map +1 -0
- package/dist/server/services/websocket/msg-id-tracker.js +77 -0
- package/dist/server/services/websocket/msg-id-tracker.js.map +1 -0
- package/dist/server/services/websocket/quality-handlers.js +15 -3
- package/dist/server/services/websocket/quality-handlers.js.map +1 -1
- package/dist/server/services/websocket/quality-review-agent.js +2 -2
- package/dist/server/services/websocket/session-handlers.d.ts +48 -2
- package/dist/server/services/websocket/session-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/session-handlers.js +204 -65
- package/dist/server/services/websocket/session-handlers.js.map +1 -1
- package/dist/server/services/websocket/session-initialization.d.ts +2 -2
- package/dist/server/services/websocket/session-initialization.d.ts.map +1 -1
- package/dist/server/services/websocket/session-initialization.js +75 -17
- package/dist/server/services/websocket/session-initialization.js.map +1 -1
- package/dist/server/services/websocket/session-registry.d.ts +29 -1
- package/dist/server/services/websocket/session-registry.d.ts.map +1 -1
- package/dist/server/services/websocket/session-registry.js +53 -4
- package/dist/server/services/websocket/session-registry.js.map +1 -1
- package/dist/server/services/websocket/tab-broadcast.d.ts +24 -0
- package/dist/server/services/websocket/tab-broadcast.d.ts.map +1 -0
- package/dist/server/services/websocket/tab-broadcast.js +13 -0
- package/dist/server/services/websocket/tab-broadcast.js.map +1 -0
- package/dist/server/services/websocket/tab-event-buffer.d.ts +103 -0
- package/dist/server/services/websocket/tab-event-buffer.d.ts.map +1 -0
- package/dist/server/services/websocket/tab-event-buffer.js +107 -0
- package/dist/server/services/websocket/tab-event-buffer.js.map +1 -0
- package/dist/server/services/websocket/tab-event-replay.d.ts +20 -0
- package/dist/server/services/websocket/tab-event-replay.d.ts.map +1 -0
- package/dist/server/services/websocket/tab-event-replay.js +21 -0
- package/dist/server/services/websocket/tab-event-replay.js.map +1 -0
- package/dist/server/services/websocket/tab-handlers.d.ts +0 -1
- package/dist/server/services/websocket/tab-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/tab-handlers.js +2 -9
- package/dist/server/services/websocket/tab-handlers.js.map +1 -1
- package/dist/server/services/websocket/types.d.ts +15 -6
- package/dist/server/services/websocket/types.d.ts.map +1 -1
- package/dist/server/services/websocket/types.js +6 -4
- package/dist/server/services/websocket/types.js.map +1 -1
- package/package.json +1 -1
- package/server/README.md +1 -1
- package/server/cli/headless/claude-invoker-stall.ts +7 -2
- package/server/cli/headless/claude-invoker.ts +1 -1
- package/server/cli/headless/runner.ts +67 -72
- package/server/cli/headless/stall-assessor.ts +9 -4
- package/server/cli/headless/types.ts +1 -1
- package/server/cli/improvisation-history-store.ts +62 -0
- package/server/cli/improvisation-movements.ts +120 -0
- package/server/cli/improvisation-output-queue.ts +42 -0
- package/server/cli/improvisation-retry.ts +25 -600
- package/server/cli/improvisation-session-manager.ts +74 -160
- package/server/cli/retry/retry-best-result.ts +70 -0
- package/server/cli/retry/retry-context-loss.ts +87 -0
- package/server/cli/retry/retry-premature-completion.ts +113 -0
- package/server/cli/retry/retry-recovery-strategies.ts +247 -0
- package/server/cli/retry/retry-resume-strategy.ts +33 -0
- package/server/cli/retry/retry-runner-factory.ts +70 -0
- package/server/cli/retry/retry-tool-results.ts +31 -0
- package/server/cli/retry/retry-types.ts +32 -0
- package/server/index.ts +37 -123
- package/server/server-setup.ts +126 -1
- package/server/services/plan/agents/assess-stall.md +11 -4
- package/server/services/plan/board-config.ts +122 -0
- package/server/services/plan/composer.ts +7 -5
- package/server/services/plan/executor.ts +214 -467
- package/server/services/plan/issue-loader.ts +64 -0
- package/server/services/plan/issue-writer.ts +137 -0
- package/server/services/plan/output-manager.ts +2 -1
- package/server/services/plan/progress-log.ts +92 -0
- package/server/services/plan/prompt-builder.ts +73 -35
- package/server/services/plan/readiness-planner.ts +50 -0
- package/server/services/plan/review-gate.ts +102 -2
- package/server/services/platform.ts +163 -58
- package/server/services/websocket/file-download-handler.ts +191 -0
- package/server/services/websocket/git-branch-handlers.ts +28 -1
- package/server/services/websocket/git-handlers.ts +1 -1
- package/server/services/websocket/git-worktree-handlers.ts +31 -4
- package/server/services/websocket/handler-context.ts +15 -0
- package/server/services/websocket/handler.ts +76 -12
- package/server/services/websocket/msg-id-tracker.ts +84 -0
- package/server/services/websocket/quality-handlers.ts +16 -3
- package/server/services/websocket/quality-review-agent.ts +2 -2
- package/server/services/websocket/session-handlers.ts +213 -68
- package/server/services/websocket/session-initialization.ts +83 -19
- package/server/services/websocket/session-registry.ts +61 -4
- package/server/services/websocket/tab-broadcast.ts +38 -0
- package/server/services/websocket/tab-event-buffer.ts +159 -0
- package/server/services/websocket/tab-event-replay.ts +42 -0
- package/server/services/websocket/tab-handlers.ts +2 -9
- package/server/services/websocket/types.ts +17 -4
|
@@ -90,9 +90,17 @@ export class PlatformConnection {
|
|
|
90
90
|
this.startedAt = new Date().toISOString()
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
|
|
93
|
+
/**
|
|
94
|
+
* Refresh the device token if it's older than the refresh interval.
|
|
95
|
+
* Returns `true` if the token is (still) valid after this call, `false`
|
|
96
|
+
* if refresh was attempted and rejected with an auth error — in which
|
|
97
|
+
* case the caller should surface an auth-expired signal to the web
|
|
98
|
+
* rather than silently reusing a dead token.
|
|
99
|
+
*/
|
|
100
|
+
private async maybeRefreshToken(): Promise<boolean> {
|
|
94
101
|
const creds = getCredentials()
|
|
95
|
-
if (!creds
|
|
102
|
+
if (!creds) return false
|
|
103
|
+
if (!shouldRefreshToken(creds)) return true
|
|
96
104
|
|
|
97
105
|
try {
|
|
98
106
|
const response = await fetch(`${this.platformUrl}/api/auth/device/refresh`, {
|
|
@@ -109,14 +117,85 @@ export class PlatformConnection {
|
|
|
109
117
|
token: data.accessToken,
|
|
110
118
|
lastRefreshedAt: new Date().toISOString()
|
|
111
119
|
})
|
|
112
|
-
|
|
113
|
-
|
|
120
|
+
return true
|
|
121
|
+
}
|
|
122
|
+
if (response.status === 401 || response.status === 403) {
|
|
123
|
+
console.warn(`[Platform] Token refresh failed — auth is expired (${response.status}). Run \`mstro login --force\`.`)
|
|
124
|
+
this.notifyAuthExpired()
|
|
125
|
+
return false
|
|
114
126
|
}
|
|
127
|
+
console.warn(`[Platform] Token refresh failed with status ${response.status}, will retry later`)
|
|
128
|
+
return true
|
|
115
129
|
} catch (err) {
|
|
116
130
|
console.warn('[Platform] Token refresh error:', err)
|
|
131
|
+
return true
|
|
117
132
|
}
|
|
118
133
|
}
|
|
119
134
|
|
|
135
|
+
/**
|
|
136
|
+
* Verify the current token against the platform. A rejection (401/403)
|
|
137
|
+
* means the token is permanently invalid (revoked, signing-key rotation,
|
|
138
|
+
* account deleted); the caller should stop looping reconnects and tell
|
|
139
|
+
* the user to run `mstro login --force`.
|
|
140
|
+
*
|
|
141
|
+
* Returns `true` when the token is valid or the verification endpoint
|
|
142
|
+
* is unreachable (we prefer false negatives to false positives — a
|
|
143
|
+
* network blip shouldn't force a re-login).
|
|
144
|
+
*/
|
|
145
|
+
private async verifyToken(): Promise<boolean> {
|
|
146
|
+
const creds = getCredentials()
|
|
147
|
+
if (!creds?.token) return false
|
|
148
|
+
try {
|
|
149
|
+
const response = await fetch(`${this.platformUrl}/api/auth/device/verify`, {
|
|
150
|
+
method: 'POST',
|
|
151
|
+
headers: { 'Authorization': `Bearer ${creds.token}` },
|
|
152
|
+
})
|
|
153
|
+
if (response.status === 401 || response.status === 403) {
|
|
154
|
+
console.warn(`[Platform] Token verify rejected (${response.status}) — auth is expired.`)
|
|
155
|
+
return false
|
|
156
|
+
}
|
|
157
|
+
return true
|
|
158
|
+
} catch {
|
|
159
|
+
// Network error: treat as "probably valid" so a flaky connection
|
|
160
|
+
// doesn't force users to re-login. The WebSocket open itself will
|
|
161
|
+
// catch a truly bad token via the 4001 path.
|
|
162
|
+
return true
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Surface an auth-expired condition to any paired web clients.
|
|
168
|
+
*
|
|
169
|
+
* Two cooperating paths deliver this signal — either alone is enough,
|
|
170
|
+
* both together cover every timing edge:
|
|
171
|
+
*
|
|
172
|
+
* 1. **CLI-initiated (this method):** we detected a 401 from the
|
|
173
|
+
* `/refresh` or `/verify` endpoint *while the relay socket is
|
|
174
|
+
* still open*. `this.send` pushes the message upstream so the
|
|
175
|
+
* server relays it to paired webs before we intentionally close.
|
|
176
|
+
* A no-op if the socket is already closed.
|
|
177
|
+
*
|
|
178
|
+
* 2. **Server-initiated:** when the platform closes a CLI socket
|
|
179
|
+
* with 4001 or 4008, `handleAuthClose` in `clientHandlers.ts`
|
|
180
|
+
* broadcasts the same `clientAuthExpired` to paired webs. This
|
|
181
|
+
* covers the cases where the CLI never had a chance to detect
|
|
182
|
+
* the rejection itself (e.g. token revoked while the socket was
|
|
183
|
+
* idle, server-side token rotation).
|
|
184
|
+
*
|
|
185
|
+
* IMPORTANT: never call `this.callbacks.onRelayedMessage` here —
|
|
186
|
+
* that callback feeds INCOMING web→CLI requests into the local handler,
|
|
187
|
+
* which would treat `clientAuthExpired` as an unknown inbound request.
|
|
188
|
+
*/
|
|
189
|
+
private notifyAuthExpired(): void {
|
|
190
|
+
this.send({
|
|
191
|
+
type: 'clientAuthExpired',
|
|
192
|
+
data: {
|
|
193
|
+
connectionId: this.connectionId,
|
|
194
|
+
message: 'The CLI\'s device token is invalid — run `mstro login --force` on the machine.',
|
|
195
|
+
},
|
|
196
|
+
})
|
|
197
|
+
}
|
|
198
|
+
|
|
120
199
|
private startTokenRefreshCheck(): void {
|
|
121
200
|
this.tokenRefreshInterval = setInterval(() => {
|
|
122
201
|
this.maybeRefreshToken()
|
|
@@ -162,39 +241,15 @@ export class PlatformConnection {
|
|
|
162
241
|
|
|
163
242
|
connect(): void {
|
|
164
243
|
this.isIntentionallyClosed = false
|
|
165
|
-
const name = basename(this.workingDirectory)
|
|
166
|
-
const machineHostname = hostname()
|
|
167
|
-
const clientId = getClientId()
|
|
168
|
-
const machineId = getMachineIdentifier()
|
|
169
|
-
const nodeVersion = process.version
|
|
170
|
-
const osType = type().toLowerCase()
|
|
171
|
-
const cpuArch = arch()
|
|
172
|
-
|
|
173
|
-
const credentials = getCredentials()
|
|
174
|
-
const authToken = credentials?.token
|
|
175
244
|
|
|
245
|
+
const authToken = getCredentials()?.token
|
|
176
246
|
if (!authToken) {
|
|
177
247
|
console.error('\n❌ Not logged in. Run `mstro login` first.\n')
|
|
178
248
|
this.callbacks.onError?.('Not logged in - run `mstro login` first')
|
|
179
249
|
return
|
|
180
250
|
}
|
|
181
251
|
|
|
182
|
-
const
|
|
183
|
-
name,
|
|
184
|
-
workingDirectory: this.workingDirectory,
|
|
185
|
-
machineHostname,
|
|
186
|
-
clientId,
|
|
187
|
-
machineId,
|
|
188
|
-
nodeVersion,
|
|
189
|
-
osType,
|
|
190
|
-
cpuArch,
|
|
191
|
-
cliVersion: CLI_VERSION,
|
|
192
|
-
capabilities: JSON.stringify({}),
|
|
193
|
-
startedAt: this.startedAt,
|
|
194
|
-
})
|
|
195
|
-
|
|
196
|
-
const wsUrl = `${this.platformUrl.replace(/^http/, 'ws')}/ws/client?${params}`
|
|
197
|
-
|
|
252
|
+
const wsUrl = this.buildConnectionUrl()
|
|
198
253
|
try {
|
|
199
254
|
this.ws = new WebSocketImpl(wsUrl)
|
|
200
255
|
} catch (err) {
|
|
@@ -205,7 +260,47 @@ export class PlatformConnection {
|
|
|
205
260
|
return
|
|
206
261
|
}
|
|
207
262
|
|
|
208
|
-
const connectionTimeout =
|
|
263
|
+
const connectionTimeout = this.startConnectionTimeout()
|
|
264
|
+
this.attachSocketHandlers(this.ws, authToken, connectionTimeout)
|
|
265
|
+
this.maybeVerifyTokenInParallel()
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Best-effort token verification, fired in parallel with the socket
|
|
270
|
+
* open so a slow verify endpoint never delays reconnect.
|
|
271
|
+
*
|
|
272
|
+
* Only runs when the token is stale enough that we'd be about to
|
|
273
|
+
* refresh anyway — keeps the hot path free of an extra network call.
|
|
274
|
+
* A truly-revoked token that slips past this check still hits 4001
|
|
275
|
+
* on the WebSocket, which also triggers `notifyAuthExpired`.
|
|
276
|
+
*/
|
|
277
|
+
private maybeVerifyTokenInParallel(): void {
|
|
278
|
+
const creds = getCredentials()
|
|
279
|
+
if (!creds || !shouldRefreshToken(creds)) return
|
|
280
|
+
this.verifyToken().then((valid) => {
|
|
281
|
+
if (!valid) this.notifyAuthExpired()
|
|
282
|
+
}).catch(() => { /* network error — rely on 4001 close path */ })
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private buildConnectionUrl(): string {
|
|
286
|
+
const params = new URLSearchParams({
|
|
287
|
+
name: basename(this.workingDirectory),
|
|
288
|
+
workingDirectory: this.workingDirectory,
|
|
289
|
+
machineHostname: hostname(),
|
|
290
|
+
clientId: getClientId(),
|
|
291
|
+
machineId: getMachineIdentifier(),
|
|
292
|
+
nodeVersion: process.version,
|
|
293
|
+
osType: type().toLowerCase(),
|
|
294
|
+
cpuArch: arch(),
|
|
295
|
+
cliVersion: CLI_VERSION,
|
|
296
|
+
capabilities: JSON.stringify({}),
|
|
297
|
+
startedAt: this.startedAt,
|
|
298
|
+
})
|
|
299
|
+
return `${this.platformUrl.replace(/^http/, 'ws')}/ws/client?${params}`
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
private startConnectionTimeout(): ReturnType<typeof setTimeout> {
|
|
303
|
+
return setTimeout(() => {
|
|
209
304
|
const state = this.ws?.readyState
|
|
210
305
|
if (this.ws && (state === 0 || state === undefined)) {
|
|
211
306
|
console.error('\n❌ Connection timeout. The platform may have rejected your credentials.')
|
|
@@ -214,55 +309,65 @@ export class PlatformConnection {
|
|
|
214
309
|
this.callbacks.onError?.('Connection timeout - run `mstro login --force`')
|
|
215
310
|
}
|
|
216
311
|
}, 10000)
|
|
312
|
+
}
|
|
217
313
|
|
|
218
|
-
|
|
314
|
+
private attachSocketHandlers(
|
|
315
|
+
ws: WebSocket,
|
|
316
|
+
authToken: string,
|
|
317
|
+
connectionTimeout: ReturnType<typeof setTimeout>,
|
|
318
|
+
): void {
|
|
319
|
+
ws.onopen = () => {
|
|
219
320
|
clearTimeout(connectionTimeout)
|
|
220
|
-
|
|
321
|
+
ws.send(JSON.stringify({ type: 'auth', token: authToken }))
|
|
221
322
|
this.maybeRefreshToken()
|
|
222
323
|
this.startTokenRefreshCheck()
|
|
223
324
|
this.reconnectAttempts = 0
|
|
224
325
|
trackEvent(AnalyticsEvents.PLATFORM_CONNECTED)
|
|
225
326
|
}
|
|
226
327
|
|
|
227
|
-
|
|
328
|
+
ws.onmessage = (event) => {
|
|
228
329
|
try {
|
|
229
|
-
|
|
230
|
-
this.handleMessage(message)
|
|
330
|
+
this.handleMessage(JSON.parse(event.data.toString()))
|
|
231
331
|
} catch (err) {
|
|
232
332
|
console.error('Failed to parse platform message:', err)
|
|
233
333
|
}
|
|
234
334
|
}
|
|
235
335
|
|
|
236
|
-
|
|
336
|
+
ws.onclose = (event) => {
|
|
237
337
|
clearTimeout(connectionTimeout)
|
|
238
|
-
this.
|
|
239
|
-
this.isConnected = false
|
|
240
|
-
|
|
241
|
-
if (!this.isIntentionallyClosed) {
|
|
242
|
-
const isAuthFailure = event.code === 4001 ||
|
|
243
|
-
event.reason?.includes('Unauthorized') ||
|
|
244
|
-
(event.code === 1006 && !this.everConnected)
|
|
245
|
-
|
|
246
|
-
if (isAuthFailure) {
|
|
247
|
-
console.error('\n❌ Authentication failed. Your device token may be invalid or expired.')
|
|
248
|
-
console.error(' Run `mstro login --force` to re-authenticate.\n')
|
|
249
|
-
this.callbacks.onError?.('Authentication failed - run `mstro login --force`')
|
|
250
|
-
return
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
console.log('Disconnected, reconnecting...')
|
|
254
|
-
this.callbacks.onDisconnected?.()
|
|
255
|
-
trackEvent(AnalyticsEvents.PLATFORM_DISCONNECTED)
|
|
256
|
-
this.scheduleReconnect()
|
|
257
|
-
}
|
|
338
|
+
this.handleSocketClose(event)
|
|
258
339
|
}
|
|
259
340
|
|
|
260
|
-
|
|
341
|
+
ws.onerror = () => {
|
|
261
342
|
clearTimeout(connectionTimeout)
|
|
262
343
|
// onclose will be called after this
|
|
263
344
|
}
|
|
264
345
|
}
|
|
265
346
|
|
|
347
|
+
private handleSocketClose(event: CloseEvent): void {
|
|
348
|
+
this.stopHeartbeat()
|
|
349
|
+
this.isConnected = false
|
|
350
|
+
|
|
351
|
+
if (this.isIntentionallyClosed) return
|
|
352
|
+
|
|
353
|
+
const isAuthFailure = event.code === 4001 ||
|
|
354
|
+
event.reason?.includes('Unauthorized') ||
|
|
355
|
+
(event.code === 1006 && !this.everConnected)
|
|
356
|
+
|
|
357
|
+
if (isAuthFailure) {
|
|
358
|
+
console.error('\n❌ Authentication failed. Your device token may be invalid or expired.')
|
|
359
|
+
console.error(' Run `mstro login --force` to re-authenticate.\n')
|
|
360
|
+
this.notifyAuthExpired()
|
|
361
|
+
this.callbacks.onError?.('Authentication failed - run `mstro login --force`')
|
|
362
|
+
return
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
console.log('Disconnected, reconnecting...')
|
|
366
|
+
this.callbacks.onDisconnected?.()
|
|
367
|
+
trackEvent(AnalyticsEvents.PLATFORM_DISCONNECTED)
|
|
368
|
+
this.scheduleReconnect()
|
|
369
|
+
}
|
|
370
|
+
|
|
266
371
|
private handleMessage(message: Record<string, unknown>): void {
|
|
267
372
|
switch (message.type) {
|
|
268
373
|
case 'paired':
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// Copyright (c) 2025-present Mstro, Inc. All rights reserved.
|
|
2
|
+
// Licensed under the MIT License. See LICENSE file for details.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Chunked File Download Handler
|
|
6
|
+
*
|
|
7
|
+
* Streams files to the web client as a sequence of WebSocket chunk messages.
|
|
8
|
+
* Mirrors the chunked upload path in reverse. Used for large binary files
|
|
9
|
+
* (images, PDFs) where a single base64-in-JSON `fileContent` message would
|
|
10
|
+
* exceed the relay's per-message cap or block the event loop during encoding.
|
|
11
|
+
*
|
|
12
|
+
* Chunk sizing: we emit 192 KB of raw bytes per chunk, which base64-encodes
|
|
13
|
+
* to ~256 KB — comfortably under the relay's 16 MB per-message limit even
|
|
14
|
+
* with JSON envelope overhead.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { createReadStream, existsSync, type ReadStream, statSync } from 'node:fs';
|
|
18
|
+
import { basename, resolve, sep } from 'node:path';
|
|
19
|
+
import { isBinaryFile, isPathInSafeLocation } from './file-utils.js';
|
|
20
|
+
import type { WebSocketResponse, WSContext } from './types.js';
|
|
21
|
+
|
|
22
|
+
const CHUNK_RAW_BYTES = 192 * 1024; // ~256 KB once base64-encoded
|
|
23
|
+
const MAX_DOWNLOAD_SIZE = 50 * 1024 * 1024;
|
|
24
|
+
const IDLE_TIMEOUT_MS = 120_000;
|
|
25
|
+
|
|
26
|
+
interface DownloadState {
|
|
27
|
+
downloadId: string;
|
|
28
|
+
stream: ReadStream;
|
|
29
|
+
cancelled: boolean;
|
|
30
|
+
lastActivity: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function getMimeType(fullPath: string): string {
|
|
34
|
+
const ext = fullPath.toLowerCase().split('.').pop() || '';
|
|
35
|
+
if (ext === 'svg') return 'image/svg+xml';
|
|
36
|
+
if (ext === 'jpg' || ext === 'jpeg') return 'image/jpeg';
|
|
37
|
+
if (ext === 'pdf') return 'application/pdf';
|
|
38
|
+
if (['png', 'gif', 'webp', 'bmp', 'tiff', 'ico'].includes(ext)) return `image/${ext}`;
|
|
39
|
+
return 'application/octet-stream';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class FileDownloadHandler {
|
|
43
|
+
private active = new Map<string, DownloadState>();
|
|
44
|
+
private cleanupInterval: ReturnType<typeof setInterval>;
|
|
45
|
+
|
|
46
|
+
constructor(private workingDir: string) {
|
|
47
|
+
this.cleanupInterval = setInterval(() => this.cleanupStale(), 30_000);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
handleDownloadStart(
|
|
51
|
+
ws: WSContext,
|
|
52
|
+
send: (ws: WSContext, response: WebSocketResponse) => void,
|
|
53
|
+
tabId: string,
|
|
54
|
+
data: { downloadId: string; filePath: string },
|
|
55
|
+
): void {
|
|
56
|
+
const { downloadId, filePath } = data;
|
|
57
|
+
const sendError = (error: string) => send(ws, {
|
|
58
|
+
type: 'fileDownloadError' as WebSocketResponse['type'],
|
|
59
|
+
tabId,
|
|
60
|
+
data: { downloadId, error },
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
const fullPath = filePath.startsWith('/') ? filePath : resolve(this.workingDir, filePath);
|
|
65
|
+
const normalized = resolve(fullPath);
|
|
66
|
+
const normalizedWorkingDir = resolve(this.workingDir);
|
|
67
|
+
|
|
68
|
+
// Sandbox: must be under workingDir OR a known safe location (matches readFileContent)
|
|
69
|
+
const withinDir = normalized === normalizedWorkingDir
|
|
70
|
+
|| normalized.startsWith(normalizedWorkingDir + sep);
|
|
71
|
+
if (!withinDir && !isPathInSafeLocation(normalized)) {
|
|
72
|
+
sendError('Access denied: path outside allowed locations');
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (!existsSync(normalized)) {
|
|
76
|
+
sendError('File not found');
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const stats = statSync(normalized);
|
|
81
|
+
if (stats.isDirectory()) {
|
|
82
|
+
sendError('Path is a directory');
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (stats.size > MAX_DOWNLOAD_SIZE) {
|
|
86
|
+
sendError(`File too large: ${(stats.size / 1024 / 1024).toFixed(1)}MB exceeds ${MAX_DOWNLOAD_SIZE / 1024 / 1024}MB limit`);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const totalChunks = Math.max(1, Math.ceil(stats.size / CHUNK_RAW_BYTES));
|
|
91
|
+
const fileName = basename(normalized);
|
|
92
|
+
const isImg = isBinaryFile(normalized);
|
|
93
|
+
|
|
94
|
+
send(ws, {
|
|
95
|
+
type: 'fileDownloadReady' as WebSocketResponse['type'],
|
|
96
|
+
tabId,
|
|
97
|
+
data: {
|
|
98
|
+
downloadId,
|
|
99
|
+
filePath,
|
|
100
|
+
fileName,
|
|
101
|
+
size: stats.size,
|
|
102
|
+
mimeType: getMimeType(normalized),
|
|
103
|
+
isImage: isImg,
|
|
104
|
+
totalChunks,
|
|
105
|
+
modifiedAt: stats.mtime.toISOString(),
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const stream = createReadStream(normalized, { highWaterMark: CHUNK_RAW_BYTES });
|
|
110
|
+
const state: DownloadState = {
|
|
111
|
+
downloadId,
|
|
112
|
+
stream,
|
|
113
|
+
cancelled: false,
|
|
114
|
+
lastActivity: Date.now(),
|
|
115
|
+
};
|
|
116
|
+
this.active.set(downloadId, state);
|
|
117
|
+
|
|
118
|
+
let chunkIndex = 0;
|
|
119
|
+
stream.on('data', (buf: Buffer | string) => {
|
|
120
|
+
if (state.cancelled) return;
|
|
121
|
+
// Node may deliver buffers larger than highWaterMark on stream edges;
|
|
122
|
+
// slice back down so each WS frame stays predictable.
|
|
123
|
+
const chunk = typeof buf === 'string' ? Buffer.from(buf) : buf;
|
|
124
|
+
for (let off = 0; off < chunk.length; off += CHUNK_RAW_BYTES) {
|
|
125
|
+
const slice = chunk.subarray(off, Math.min(off + CHUNK_RAW_BYTES, chunk.length));
|
|
126
|
+
send(ws, {
|
|
127
|
+
type: 'fileDownloadChunk' as WebSocketResponse['type'],
|
|
128
|
+
tabId,
|
|
129
|
+
data: {
|
|
130
|
+
downloadId,
|
|
131
|
+
chunkIndex: chunkIndex++,
|
|
132
|
+
content: slice.toString('base64'),
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
state.lastActivity = Date.now();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
stream.on('end', () => {
|
|
140
|
+
if (state.cancelled) return;
|
|
141
|
+
send(ws, {
|
|
142
|
+
type: 'fileDownloadComplete' as WebSocketResponse['type'],
|
|
143
|
+
tabId,
|
|
144
|
+
data: { downloadId },
|
|
145
|
+
});
|
|
146
|
+
this.active.delete(downloadId);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
stream.on('error', (err) => {
|
|
150
|
+
sendError(`Read failed: ${err.message}`);
|
|
151
|
+
this.active.delete(downloadId);
|
|
152
|
+
});
|
|
153
|
+
} catch (err) {
|
|
154
|
+
sendError(err instanceof Error ? err.message : String(err));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
handleDownloadCancel(
|
|
159
|
+
_ws: WSContext,
|
|
160
|
+
_send: (ws: WSContext, response: WebSocketResponse) => void,
|
|
161
|
+
_tabId: string,
|
|
162
|
+
data: { downloadId: string },
|
|
163
|
+
): void {
|
|
164
|
+
const state = this.active.get(data.downloadId);
|
|
165
|
+
if (!state) return;
|
|
166
|
+
state.cancelled = true;
|
|
167
|
+
state.stream.destroy();
|
|
168
|
+
this.active.delete(data.downloadId);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private cleanupStale(): void {
|
|
172
|
+
const now = Date.now();
|
|
173
|
+
for (const [id, state] of this.active) {
|
|
174
|
+
if (now - state.lastActivity > IDLE_TIMEOUT_MS) {
|
|
175
|
+
console.warn(`[FileDownloadHandler] Download ${id} idle, cleaning up`);
|
|
176
|
+
state.cancelled = true;
|
|
177
|
+
state.stream.destroy();
|
|
178
|
+
this.active.delete(id);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
destroy(): void {
|
|
184
|
+
clearInterval(this.cleanupInterval);
|
|
185
|
+
for (const state of this.active.values()) {
|
|
186
|
+
state.cancelled = true;
|
|
187
|
+
state.stream.destroy();
|
|
188
|
+
}
|
|
189
|
+
this.active.clear();
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// Licensed under the MIT License. See LICENSE file for details.
|
|
3
3
|
|
|
4
4
|
import { executeGitCommand, sendGitError } from './git-utils.js';
|
|
5
|
+
import { findWorktreePathForBranch, handleTabWorktreeSwitch } from './git-worktree-handlers.js';
|
|
5
6
|
import type { HandlerContext } from './handler-context.js';
|
|
6
7
|
import type { GitBranchEntry, WebSocketMessage, WSContext } from './types.js';
|
|
7
8
|
|
|
@@ -40,7 +41,28 @@ export async function handleGitListBranches(ctx: HandlerContext, ws: WSContext,
|
|
|
40
41
|
}
|
|
41
42
|
}
|
|
42
43
|
|
|
43
|
-
|
|
44
|
+
/**
|
|
45
|
+
* If `branch` is already checked out in a worktree other than `workingDir`, redirect the tab
|
|
46
|
+
* there via the worktree-switch flow and return true. Git refuses `checkout` in this case, so
|
|
47
|
+
* treating it as a view change is both correct and the only thing that works.
|
|
48
|
+
*/
|
|
49
|
+
async function redirectToWorktreeIfBranchCheckedOut(
|
|
50
|
+
ctx: HandlerContext, ws: WSContext,
|
|
51
|
+
tabId: string, branch: string, workingDir: string, rootWorkingDir: string,
|
|
52
|
+
): Promise<boolean> {
|
|
53
|
+
const wtListResult = await executeGitCommand(['worktree', 'list', '--porcelain'], workingDir);
|
|
54
|
+
if (wtListResult.exitCode !== 0) return false;
|
|
55
|
+
const existingWt = findWorktreePathForBranch(wtListResult.stdout, branch);
|
|
56
|
+
if (!existingWt || existingWt === workingDir) return false;
|
|
57
|
+
await handleTabWorktreeSwitch(
|
|
58
|
+
ctx, ws,
|
|
59
|
+
{ type: 'tabWorktreeSwitch', tabId, data: { tabId, worktreePath: existingWt } },
|
|
60
|
+
tabId, rootWorkingDir,
|
|
61
|
+
);
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function handleGitCheckout(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string, rootWorkingDir: string): Promise<void> {
|
|
44
66
|
try {
|
|
45
67
|
const { branch, create, startPoint } = msg.data || {};
|
|
46
68
|
if (!branch) {
|
|
@@ -48,6 +70,11 @@ export async function handleGitCheckout(ctx: HandlerContext, ws: WSContext, msg:
|
|
|
48
70
|
return;
|
|
49
71
|
}
|
|
50
72
|
|
|
73
|
+
// Skip the worktree redirect for `create` — a name collision there is a real user error.
|
|
74
|
+
if (!create && await redirectToWorktreeIfBranchCheckedOut(ctx, ws, tabId, branch, workingDir, rootWorkingDir)) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
51
78
|
const statusResult = await executeGitCommand(['status', '--porcelain'], workingDir);
|
|
52
79
|
if (statusResult.stdout.trim()) {
|
|
53
80
|
ctx.send(ws, { type: 'gitError', tabId, data: { error: 'Commit or stash changes before switching branches' } });
|
|
@@ -53,7 +53,7 @@ export async function handleGitMessage(ctx: HandlerContext, ws: WSContext, msg:
|
|
|
53
53
|
gitDiscoverRepos: () => handleGitDiscoverRepos(ctx, ws, tabId, workingDir),
|
|
54
54
|
gitSetDirectory: () => handleGitSetDirectory(ctx, ws, msg, tabId, workingDir),
|
|
55
55
|
gitListBranches: () => handleGitListBranches(ctx, ws, tabId, gitDir),
|
|
56
|
-
gitCheckout: () => handleGitCheckout(ctx, ws, msg, tabId, gitDir),
|
|
56
|
+
gitCheckout: () => handleGitCheckout(ctx, ws, msg, tabId, gitDir, workingDir),
|
|
57
57
|
gitCreateBranch: () => handleGitCreateBranch(ctx, ws, msg, tabId, gitDir),
|
|
58
58
|
gitDeleteBranch: () => handleGitDeleteBranch(ctx, ws, msg, tabId, gitDir),
|
|
59
59
|
gitDiff: () => handleGitDiff(ctx, ws, msg, tabId, gitDir),
|
|
@@ -6,6 +6,7 @@ import { dirname, join } from 'node:path';
|
|
|
6
6
|
import { resolvePmDir } from '../plan/parser.js';
|
|
7
7
|
import type { Workspace } from '../plan/types.js';
|
|
8
8
|
import { executeGitCommand, handleGitStatus, spawnWithOutput } from './git-handlers.js';
|
|
9
|
+
import { handleGitLog } from './git-log-handlers.js';
|
|
9
10
|
import type { HandlerContext } from './handler-context.js';
|
|
10
11
|
import type { WebSocketMessage, WorktreeInfo, WSContext } from './types.js';
|
|
11
12
|
|
|
@@ -201,7 +202,7 @@ async function handleGitWorktreeRemove(ctx: HandlerContext, ws: WSContext, msg:
|
|
|
201
202
|
}
|
|
202
203
|
}
|
|
203
204
|
|
|
204
|
-
async function handleTabWorktreeSwitch(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): Promise<void> {
|
|
205
|
+
export async function handleTabWorktreeSwitch(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): Promise<void> {
|
|
205
206
|
try {
|
|
206
207
|
const { tabId: targetTabId, worktreePath } = msg.data || {};
|
|
207
208
|
const resolvedTabId = targetTabId || tabId;
|
|
@@ -214,7 +215,7 @@ async function handleTabWorktreeSwitch(ctx: HandlerContext, ws: WSContext, msg:
|
|
|
214
215
|
persistBoardWorktree(workingDir, resolvedTabId, null, null);
|
|
215
216
|
}
|
|
216
217
|
ctx.send(ws, { type: 'tabWorktreeSwitched', tabId: resolvedTabId, data: { tabId: resolvedTabId, worktreePath: null, branch: null } });
|
|
217
|
-
|
|
218
|
+
refreshScopeAfterWorktreeSwitch(ctx, ws, resolvedTabId, workingDir);
|
|
218
219
|
return;
|
|
219
220
|
}
|
|
220
221
|
|
|
@@ -229,12 +230,38 @@ async function handleTabWorktreeSwitch(ctx: HandlerContext, ws: WSContext, msg:
|
|
|
229
230
|
}
|
|
230
231
|
|
|
231
232
|
ctx.send(ws, { type: 'tabWorktreeSwitched', tabId: resolvedTabId, data: { tabId: resolvedTabId, worktreePath, branch } });
|
|
232
|
-
|
|
233
|
+
refreshScopeAfterWorktreeSwitch(ctx, ws, resolvedTabId, worktreePath);
|
|
233
234
|
} catch (error: unknown) {
|
|
234
235
|
ctx.send(ws, { type: 'gitError', tabId, data: { error: error instanceof Error ? error.message : String(error) } });
|
|
235
236
|
}
|
|
236
237
|
}
|
|
237
238
|
|
|
239
|
+
/**
|
|
240
|
+
* After a worktree switch, re-fetch everything that's worktree-specific so
|
|
241
|
+
* the client sees a complete, consistent view of the newly-selected workspace
|
|
242
|
+
* from a single `tabWorktreeSwitched` event. Keeping the refresh on the
|
|
243
|
+
* server side means the client has one signal to react to instead of having
|
|
244
|
+
* to orchestrate status/log/... fetches itself.
|
|
245
|
+
*
|
|
246
|
+
* Branches and the worktree list are NOT re-fetched: they're repo-wide, not
|
|
247
|
+
* worktree-specific.
|
|
248
|
+
*
|
|
249
|
+
* Fire-and-forget: the switch itself has already been acknowledged via
|
|
250
|
+
* `tabWorktreeSwitched`. `handleGitStatus` and `handleGitLog` each own their
|
|
251
|
+
* own error handling (they send `gitError` scoped to the correct tabId).
|
|
252
|
+
* Awaiting here would let any unexpected throw escape into the caller's
|
|
253
|
+
* outer try/catch and produce a misleading `gitError` on the original
|
|
254
|
+
* dispatch tabId after success has already been signalled.
|
|
255
|
+
*/
|
|
256
|
+
function refreshScopeAfterWorktreeSwitch(ctx: HandlerContext, ws: WSContext, tabId: string, gitDir: string): void {
|
|
257
|
+
(async () => {
|
|
258
|
+
await handleGitStatus(ctx, ws, tabId, gitDir);
|
|
259
|
+
await handleGitLog(ctx, ws, { type: 'gitLog', tabId, data: { limit: 20 } }, tabId, gitDir);
|
|
260
|
+
})().catch((error: unknown) => {
|
|
261
|
+
console.error('[handleTabWorktreeSwitch] scope refresh failed:', error);
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
238
265
|
async function pushWithUpstreamRetry(
|
|
239
266
|
worktreePath: string,
|
|
240
267
|
pushRemote: string,
|
|
@@ -445,7 +472,7 @@ async function cleanupAfterMerge(
|
|
|
445
472
|
return { warnings, removedWorktreePath };
|
|
446
473
|
}
|
|
447
474
|
|
|
448
|
-
function findWorktreePathForBranch(porcelainOutput: string, branchName: string): string | null {
|
|
475
|
+
export function findWorktreePathForBranch(porcelainOutput: string, branchName: string): string | null {
|
|
449
476
|
let currentWtPath = '';
|
|
450
477
|
const fullRef = `refs/heads/${branchName}`;
|
|
451
478
|
for (const line of porcelainOutput.split('\n')) {
|
|
@@ -6,8 +6,10 @@ import type { ImprovisationSessionManager } from '../../cli/improvisation-sessio
|
|
|
6
6
|
import type { AutocompleteService } from './autocomplete.js';
|
|
7
7
|
import type { FileUploadHandler } from './file-upload-handler.js';
|
|
8
8
|
import type { GitHeadWatcher } from './git-head-watcher.js';
|
|
9
|
+
import type { MsgIdTracker } from './msg-id-tracker.js';
|
|
9
10
|
import type { SessionRegistry } from './session-registry.js';
|
|
10
11
|
import type { SkillsWatcher } from './skill-watcher.js';
|
|
12
|
+
import type { TabEventBufferRegistry } from './tab-event-buffer.js';
|
|
11
13
|
import type { WebSocketResponse, WSContext } from './types.js';
|
|
12
14
|
|
|
13
15
|
export interface UsageReport {
|
|
@@ -37,6 +39,19 @@ export interface HandlerContext {
|
|
|
37
39
|
fileUploadHandler: FileUploadHandler | null;
|
|
38
40
|
gitHeadWatcher: GitHeadWatcher | null;
|
|
39
41
|
skillsWatcher: SkillsWatcher | null;
|
|
42
|
+
/**
|
|
43
|
+
* Per-tab replay buffer for tab-scoped broadcasts. Populated by
|
|
44
|
+
* `broadcastTabEvent` (see `tab-broadcast.ts`) so a web client rejoining a
|
|
45
|
+
* tab after a reconnect can request replay of anything it missed during
|
|
46
|
+
* the transport gap. See `tab-event-buffer.ts`.
|
|
47
|
+
*/
|
|
48
|
+
tabEventBuffers: TabEventBufferRegistry;
|
|
49
|
+
/**
|
|
50
|
+
* Idempotency tracker for `execute` `msgId`s. Lets the web replay the
|
|
51
|
+
* same prompt across reconnects without causing double execution — the
|
|
52
|
+
* CLI still acks, but skips running the prompt a second time.
|
|
53
|
+
*/
|
|
54
|
+
msgIdTracker: MsgIdTracker;
|
|
40
55
|
|
|
41
56
|
// Registry access
|
|
42
57
|
getRegistry(workingDir: string): SessionRegistry;
|