crewly 1.20.127 → 1.20.129

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 (153) hide show
  1. package/config/templates/growth-marketing-team.json +12 -1
  2. package/config/templates/personal-assistant-team.json +28 -0
  3. package/dist/backend/backend/src/constants.d.ts +75 -0
  4. package/dist/backend/backend/src/constants.d.ts.map +1 -1
  5. package/dist/backend/backend/src/constants.js +81 -0
  6. package/dist/backend/backend/src/constants.js.map +1 -1
  7. package/dist/backend/backend/src/controllers/chat/chat.controller.d.ts +49 -0
  8. package/dist/backend/backend/src/controllers/chat/chat.controller.d.ts.map +1 -1
  9. package/dist/backend/backend/src/controllers/chat/chat.controller.js +135 -106
  10. package/dist/backend/backend/src/controllers/chat/chat.controller.js.map +1 -1
  11. package/dist/backend/backend/src/controllers/onboarding/index.d.ts +1 -0
  12. package/dist/backend/backend/src/controllers/onboarding/index.d.ts.map +1 -1
  13. package/dist/backend/backend/src/controllers/onboarding/index.js +1 -0
  14. package/dist/backend/backend/src/controllers/onboarding/index.js.map +1 -1
  15. package/dist/backend/backend/src/controllers/onboarding/onboarding-checklist.controller.d.ts +38 -0
  16. package/dist/backend/backend/src/controllers/onboarding/onboarding-checklist.controller.d.ts.map +1 -0
  17. package/dist/backend/backend/src/controllers/onboarding/onboarding-checklist.controller.js +106 -0
  18. package/dist/backend/backend/src/controllers/onboarding/onboarding-checklist.controller.js.map +1 -0
  19. package/dist/backend/backend/src/controllers/onboarding/onboarding-checklist.routes.d.ts +24 -0
  20. package/dist/backend/backend/src/controllers/onboarding/onboarding-checklist.routes.d.ts.map +1 -0
  21. package/dist/backend/backend/src/controllers/onboarding/onboarding-checklist.routes.js +34 -0
  22. package/dist/backend/backend/src/controllers/onboarding/onboarding-checklist.routes.js.map +1 -0
  23. package/dist/backend/backend/src/index.d.ts.map +1 -1
  24. package/dist/backend/backend/src/index.js +48 -1
  25. package/dist/backend/backend/src/index.js.map +1 -1
  26. package/dist/backend/backend/src/routes/api.routes.d.ts.map +1 -1
  27. package/dist/backend/backend/src/routes/api.routes.js +5 -0
  28. package/dist/backend/backend/src/routes/api.routes.js.map +1 -1
  29. package/dist/backend/backend/src/services/agent/oauth-relogin-monitor.service.d.ts +33 -0
  30. package/dist/backend/backend/src/services/agent/oauth-relogin-monitor.service.d.ts.map +1 -1
  31. package/dist/backend/backend/src/services/agent/oauth-relogin-monitor.service.js +63 -0
  32. package/dist/backend/backend/src/services/agent/oauth-relogin-monitor.service.js.map +1 -1
  33. package/dist/backend/backend/src/services/agent/relogin-agent-resumer.service.d.ts +76 -0
  34. package/dist/backend/backend/src/services/agent/relogin-agent-resumer.service.d.ts.map +1 -0
  35. package/dist/backend/backend/src/services/agent/relogin-agent-resumer.service.js +108 -0
  36. package/dist/backend/backend/src/services/agent/relogin-agent-resumer.service.js.map +1 -0
  37. package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.d.ts.map +1 -1
  38. package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.js +10 -0
  39. package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.js.map +1 -1
  40. package/dist/backend/backend/src/services/harness/harness-relogin.service.d.ts +361 -0
  41. package/dist/backend/backend/src/services/harness/harness-relogin.service.d.ts.map +1 -0
  42. package/dist/backend/backend/src/services/harness/harness-relogin.service.js +756 -0
  43. package/dist/backend/backend/src/services/harness/harness-relogin.service.js.map +1 -0
  44. package/dist/backend/backend/src/services/harness/harness.service.d.ts +16 -1
  45. package/dist/backend/backend/src/services/harness/harness.service.d.ts.map +1 -1
  46. package/dist/backend/backend/src/services/harness/harness.service.js +28 -1
  47. package/dist/backend/backend/src/services/harness/harness.service.js.map +1 -1
  48. package/dist/backend/backend/src/services/harness/harness.types.d.ts +16 -1
  49. package/dist/backend/backend/src/services/harness/harness.types.d.ts.map +1 -1
  50. package/dist/backend/backend/src/services/harness/harness.types.js.map +1 -1
  51. package/dist/backend/backend/src/services/harness/login-expiry-rules.d.ts +75 -0
  52. package/dist/backend/backend/src/services/harness/login-expiry-rules.d.ts.map +1 -0
  53. package/dist/backend/backend/src/services/harness/login-expiry-rules.js +159 -0
  54. package/dist/backend/backend/src/services/harness/login-expiry-rules.js.map +1 -0
  55. package/dist/backend/backend/src/services/onboarding/onboarding-checklist.factory.d.ts +45 -0
  56. package/dist/backend/backend/src/services/onboarding/onboarding-checklist.factory.d.ts.map +1 -0
  57. package/dist/backend/backend/src/services/onboarding/onboarding-checklist.factory.js +103 -0
  58. package/dist/backend/backend/src/services/onboarding/onboarding-checklist.factory.js.map +1 -0
  59. package/dist/backend/backend/src/services/onboarding/onboarding-checklist.service.d.ts +301 -0
  60. package/dist/backend/backend/src/services/onboarding/onboarding-checklist.service.d.ts.map +1 -0
  61. package/dist/backend/backend/src/services/onboarding/onboarding-checklist.service.js +365 -0
  62. package/dist/backend/backend/src/services/onboarding/onboarding-checklist.service.js.map +1 -0
  63. package/dist/backend/backend/src/services/onboarding/onboarding-state.store.d.ts +89 -0
  64. package/dist/backend/backend/src/services/onboarding/onboarding-state.store.d.ts.map +1 -0
  65. package/dist/backend/backend/src/services/onboarding/onboarding-state.store.js +122 -0
  66. package/dist/backend/backend/src/services/onboarding/onboarding-state.store.js.map +1 -0
  67. package/dist/backend/backend/src/services/slack/slack-orchestrator-bridge.d.ts +21 -1
  68. package/dist/backend/backend/src/services/slack/slack-orchestrator-bridge.d.ts.map +1 -1
  69. package/dist/backend/backend/src/services/slack/slack-orchestrator-bridge.js +39 -0
  70. package/dist/backend/backend/src/services/slack/slack-orchestrator-bridge.js.map +1 -1
  71. package/dist/backend/backend/src/services/slack/slack-relogin-dm.service.d.ts +80 -0
  72. package/dist/backend/backend/src/services/slack/slack-relogin-dm.service.d.ts.map +1 -0
  73. package/dist/backend/backend/src/services/slack/slack-relogin-dm.service.js +127 -0
  74. package/dist/backend/backend/src/services/slack/slack-relogin-dm.service.js.map +1 -0
  75. package/dist/backend/backend/src/services/template/template.service.d.ts +15 -1
  76. package/dist/backend/backend/src/services/template/template.service.d.ts.map +1 -1
  77. package/dist/backend/backend/src/services/template/template.service.js +22 -1
  78. package/dist/backend/backend/src/services/template/template.service.js.map +1 -1
  79. package/dist/backend/backend/src/types/team-template.types.d.ts +30 -0
  80. package/dist/backend/backend/src/types/team-template.types.d.ts.map +1 -1
  81. package/dist/backend/backend/src/types/team-template.types.js +24 -0
  82. package/dist/backend/backend/src/types/team-template.types.js.map +1 -1
  83. package/dist/backend/build-info.json +2 -2
  84. package/dist/cli/backend/src/constants.d.ts +75 -0
  85. package/dist/cli/backend/src/constants.d.ts.map +1 -1
  86. package/dist/cli/backend/src/constants.js +81 -0
  87. package/dist/cli/backend/src/constants.js.map +1 -1
  88. package/dist/cli/backend/src/services/agent/oauth-relogin-monitor.service.d.ts +33 -0
  89. package/dist/cli/backend/src/services/agent/oauth-relogin-monitor.service.d.ts.map +1 -1
  90. package/dist/cli/backend/src/services/agent/oauth-relogin-monitor.service.js +63 -0
  91. package/dist/cli/backend/src/services/agent/oauth-relogin-monitor.service.js.map +1 -1
  92. package/dist/cli/backend/src/services/harness/harness-relogin.service.d.ts +361 -0
  93. package/dist/cli/backend/src/services/harness/harness-relogin.service.d.ts.map +1 -0
  94. package/dist/cli/backend/src/services/harness/harness-relogin.service.js +756 -0
  95. package/dist/cli/backend/src/services/harness/harness-relogin.service.js.map +1 -0
  96. package/dist/cli/backend/src/services/harness/harness.service.d.ts +16 -1
  97. package/dist/cli/backend/src/services/harness/harness.service.d.ts.map +1 -1
  98. package/dist/cli/backend/src/services/harness/harness.service.js +28 -1
  99. package/dist/cli/backend/src/services/harness/harness.service.js.map +1 -1
  100. package/dist/cli/backend/src/services/harness/harness.types.d.ts +16 -1
  101. package/dist/cli/backend/src/services/harness/harness.types.d.ts.map +1 -1
  102. package/dist/cli/backend/src/services/harness/harness.types.js.map +1 -1
  103. package/dist/cli/backend/src/services/harness/login-expiry-rules.d.ts +75 -0
  104. package/dist/cli/backend/src/services/harness/login-expiry-rules.d.ts.map +1 -0
  105. package/dist/cli/backend/src/services/harness/login-expiry-rules.js +159 -0
  106. package/dist/cli/backend/src/services/harness/login-expiry-rules.js.map +1 -0
  107. package/dist/cli/backend/src/services/onboarding/onboarding-checklist.service.d.ts +301 -0
  108. package/dist/cli/backend/src/services/onboarding/onboarding-checklist.service.d.ts.map +1 -0
  109. package/dist/cli/backend/src/services/onboarding/onboarding-checklist.service.js +365 -0
  110. package/dist/cli/backend/src/services/onboarding/onboarding-checklist.service.js.map +1 -0
  111. package/dist/cli/backend/src/services/onboarding/onboarding-state.store.d.ts +89 -0
  112. package/dist/cli/backend/src/services/onboarding/onboarding-state.store.d.ts.map +1 -0
  113. package/dist/cli/backend/src/services/onboarding/onboarding-state.store.js +122 -0
  114. package/dist/cli/backend/src/services/onboarding/onboarding-state.store.js.map +1 -0
  115. package/dist/cli/backend/src/services/skill/skill-tier.service.d.ts +116 -0
  116. package/dist/cli/backend/src/services/skill/skill-tier.service.d.ts.map +1 -0
  117. package/dist/cli/backend/src/services/skill/skill-tier.service.js +155 -0
  118. package/dist/cli/backend/src/services/skill/skill-tier.service.js.map +1 -0
  119. package/dist/cli/backend/src/services/slack/slack-orchestrator-bridge.d.ts +21 -1
  120. package/dist/cli/backend/src/services/slack/slack-orchestrator-bridge.d.ts.map +1 -1
  121. package/dist/cli/backend/src/services/slack/slack-orchestrator-bridge.js +39 -0
  122. package/dist/cli/backend/src/services/slack/slack-orchestrator-bridge.js.map +1 -1
  123. package/dist/cli/backend/src/services/template/template.service.d.ts +152 -0
  124. package/dist/cli/backend/src/services/template/template.service.d.ts.map +1 -0
  125. package/dist/cli/backend/src/services/template/template.service.js +506 -0
  126. package/dist/cli/backend/src/services/template/template.service.js.map +1 -0
  127. package/dist/cli/backend/src/types/team-template.types.d.ts +30 -0
  128. package/dist/cli/backend/src/types/team-template.types.d.ts.map +1 -1
  129. package/dist/cli/backend/src/types/team-template.types.js +24 -0
  130. package/dist/cli/backend/src/types/team-template.types.js.map +1 -1
  131. package/dist/cli/cli/src/commands/onboard-checklist.d.ts +152 -0
  132. package/dist/cli/cli/src/commands/onboard-checklist.d.ts.map +1 -0
  133. package/dist/cli/cli/src/commands/onboard-checklist.js +241 -0
  134. package/dist/cli/cli/src/commands/onboard-checklist.js.map +1 -0
  135. package/dist/cli/cli/src/commands/onboard.d.ts +87 -6
  136. package/dist/cli/cli/src/commands/onboard.d.ts.map +1 -1
  137. package/dist/cli/cli/src/commands/onboard.js +203 -37
  138. package/dist/cli/cli/src/commands/onboard.js.map +1 -1
  139. package/dist/cli/cli/src/constants.d.ts +5 -0
  140. package/dist/cli/cli/src/constants.d.ts.map +1 -1
  141. package/dist/cli/cli/src/constants.js +5 -0
  142. package/dist/cli/cli/src/constants.js.map +1 -1
  143. package/dist/cli/cli/src/index.js +2 -1
  144. package/dist/cli/cli/src/index.js.map +1 -1
  145. package/dist/cli/cli/src/utils/templates.d.ts +27 -0
  146. package/dist/cli/cli/src/utils/templates.d.ts.map +1 -1
  147. package/dist/cli/cli/src/utils/templates.js +29 -0
  148. package/dist/cli/cli/src/utils/templates.js.map +1 -1
  149. package/frontend/dist/assets/index-2bdb57b9.css +42 -0
  150. package/frontend/dist/assets/{index-aaf65055.js → index-5b98bf77.js} +285 -285
  151. package/frontend/dist/index.html +2 -2
  152. package/package.json +1 -1
  153. package/frontend/dist/assets/index-0a2f783c.css +0 -42
@@ -0,0 +1,756 @@
1
+ /**
2
+ * Harness re-login coordinator — Phase 2 of onboarding: re-login over Slack.
3
+ *
4
+ * When a harness login expires, Crewly notices by itself, starts ONE login
5
+ * broker session for that harness, DMs the owner what to do on their phone,
6
+ * and resumes the agents that were stuck once the login succeeds. Nobody
7
+ * has to touch the machine.
8
+ *
9
+ * Inputs:
10
+ * - {@link HarnessReloginService.reportExpiry} — called by the OAuth
11
+ * re-login monitor when an agent's terminal output matches an expiry rule
12
+ * (login-expiry-rules.ts), and by the periodic status check of the
13
+ * orchestrator's harness ({@link HarnessReloginService.checkOrcHarness}).
14
+ * - {@link HarnessReloginService.handleOwnerReply} — an owner's Slack DM
15
+ * reply. A reply is consumed only when it belongs to a flow: Claude's
16
+ * authorization code (while Claude's login is waiting for it and the reply
17
+ * looks like a code), the retry keyword, or a reply to an unrecognised
18
+ * login screen. Everything else goes through the normal chat path.
19
+ * - The broker's `update` / `finished` events.
20
+ *
21
+ * Rules:
22
+ * - One flow per harness. Repeated detections only add stuck sessions. A
23
+ * flow that failed is restarted by a new detection at most once per
24
+ * {@link HARNESS_CONSTANTS.RELOGIN.REMIND_INTERVAL_MS} (the re-reminder),
25
+ * or at once when the owner replies `relogin` / `重新登录`.
26
+ * - If Crewly holds an API key for the harness it is used silently (no DM).
27
+ * - Claude → `subscription` (`claude setup-token`), Codex → `device`.
28
+ * - Secrets: Claude's code is passed to the broker and never stored, logged
29
+ * or echoed; DMs are built from {@link LoginSession} snapshots, which never
30
+ * contain a token, and the screen text is redacted again.
31
+ *
32
+ * @module services/harness/harness-relogin.service
33
+ */
34
+ import { HARNESS_CONSTANTS } from '../../constants.js';
35
+ import { LoggerService } from '../core/logger.service.js';
36
+ import { getHarnessCredentialsStore } from './harness-credentials.store.js';
37
+ import { getBrokerLoginMethod, getHarnessDefinition } from './harness-registry.js';
38
+ import { getHarnessService } from './harness.service.js';
39
+ import { SILENT_HARNESS_LOGGER, isHarnessId, isTerminalLoginState, } from './harness.types.js';
40
+ import { LOGIN_BROKER_EVENTS } from './login-broker.service.js';
41
+ import { redactSecrets } from './login-rules.js';
42
+ /** Harnesses the coordinator can log in (they have a broker method). */
43
+ function hasBrokerLogin(harnessId) {
44
+ return getBrokerLoginMethod(harnessId) !== undefined;
45
+ }
46
+ /**
47
+ * Display name of a harness.
48
+ *
49
+ * @param harnessId - Harness id
50
+ * @returns e.g. "Claude Code"
51
+ */
52
+ function displayName(harnessId) {
53
+ return getHarnessDefinition(harnessId)?.displayName ?? harnessId;
54
+ }
55
+ /**
56
+ * Whether a reply asks to start the login over.
57
+ *
58
+ * @param text - Owner reply
59
+ * @returns True for `relogin`, `re-login` or `重新登录`
60
+ */
61
+ export function isRetryKeyword(text) {
62
+ const normalized = text.trim().toLowerCase();
63
+ return HARNESS_CONSTANTS.RELOGIN.RETRY_KEYWORDS.some((keyword) => keyword.toLowerCase() === normalized);
64
+ }
65
+ /**
66
+ * Strip formatting Slack clients add around a pasted value (backticks).
67
+ *
68
+ * @param text - Owner reply
69
+ * @returns The bare value, trimmed
70
+ */
71
+ export function unwrapReply(text) {
72
+ return text.trim().replace(/^`+|`+$/g, '').trim();
73
+ }
74
+ /**
75
+ * Whether a reply plausibly is Claude's authorization code: one token with
76
+ * no whitespace and a plausible length.
77
+ *
78
+ * @param text - Unwrapped owner reply
79
+ * @returns True when it can be typed into `claude setup-token`
80
+ */
81
+ export function looksLikeAuthCode(text) {
82
+ const { CODE_MIN_LENGTH, CODE_MAX_LENGTH } = HARNESS_CONSTANTS.RELOGIN;
83
+ return text.length >= CODE_MIN_LENGTH && text.length <= CODE_MAX_LENGTH && !/\s/.test(text);
84
+ }
85
+ /**
86
+ * "2 agents are waiting: a, b" — capped list of waiting agents.
87
+ *
88
+ * @param agents - Session names
89
+ * @returns Sentence fragment
90
+ */
91
+ export function describeWaitingAgents(agents) {
92
+ if (agents.length === 0)
93
+ return '目前没有 agent 在用它。';
94
+ const max = HARNESS_CONSTANTS.RELOGIN.DM_MAX_LISTED_AGENTS;
95
+ const listed = agents.slice(0, max).join('、');
96
+ const more = agents.length > max ? ` 等 ${agents.length} 个` : '';
97
+ return `${listed}${more} 在等它。`;
98
+ }
99
+ /**
100
+ * Keep text safe for a DM: redact secrets, drop code fences, cap length (tail).
101
+ *
102
+ * @param text - Screen or message text
103
+ * @param max - Maximum length
104
+ * @returns Safe text
105
+ */
106
+ function safeForDm(text, max) {
107
+ const redacted = redactSecrets(text).replace(/```/g, "'''").trim();
108
+ return redacted.length > max ? redacted.slice(redacted.length - max) : redacted;
109
+ }
110
+ /** Retry hint appended to failure DMs. */
111
+ const RETRY_HINT = '回复「重新登录」(或 `relogin`)再试一次。';
112
+ /**
113
+ * Prefix an owner reply needs before it is typed into an unrecognised login
114
+ * screen. Without it, any one-line DM to the orc during the login would have
115
+ * been swallowed by the terminal.
116
+ */
117
+ export const SCREEN_REPLY_PREFIXES = ['输入', 'input'];
118
+ /**
119
+ * The text after a screen-reply prefix ("输入 1" → "1"), or null.
120
+ *
121
+ * @param reply - Owner reply (already unwrapped)
122
+ * @returns Text to type, or null when the reply has no prefix
123
+ */
124
+ export function stripScreenReplyPrefix(reply) {
125
+ for (const prefix of SCREEN_REPLY_PREFIXES) {
126
+ const re = new RegExp(`^${prefix}[\\s::]+`, 'i');
127
+ if (re.test(reply))
128
+ return reply.replace(re, '');
129
+ }
130
+ return null;
131
+ }
132
+ /**
133
+ * DM with the sign-in link (and Codex's one-time code on its own line).
134
+ *
135
+ * @param session - Broker session (never contains a secret)
136
+ * @param waiting - Agents waiting on the login
137
+ * @returns Slack mrkdwn text
138
+ */
139
+ export function formatLinkDm(session, waiting) {
140
+ const name = displayName(session.harnessId);
141
+ const lines = [`*${name} 登录过期了。* ${describeWaitingAgents(waiting)}`, ''];
142
+ if (session.method === 'device') {
143
+ lines.push('1. 在手机上打开这个链接:', session.url ?? '', '', '2. 输入这个验证码:', session.userCode ?? '', '');
144
+ lines.push('在手机上完成登录就行,这边会自动继续。');
145
+ }
146
+ else {
147
+ lines.push('1. 在手机上打开这个链接并授权:', session.url ?? '', '');
148
+ lines.push('2. 把授权后页面显示的代码直接回复在这里(只要代码)。', '');
149
+ lines.push('Crewly 会自动填进去,agent 自己接着干。');
150
+ }
151
+ return lines.join('\n');
152
+ }
153
+ /**
154
+ * DM for a login screen the broker did not recognise.
155
+ *
156
+ * @param session - Broker session
157
+ * @param waiting - Agents waiting on the login
158
+ * @returns Slack mrkdwn text
159
+ */
160
+ export function formatScreenDm(session, waiting) {
161
+ const screen = safeForDm(session.screen, HARNESS_CONSTANTS.RELOGIN.DM_SCREEN_MAX_CHARS) || '(empty screen)';
162
+ return [
163
+ `*${displayName(session.harnessId)} 登录过期了。* ${describeWaitingAgents(waiting)}`,
164
+ '',
165
+ 'Crewly 已经启动了登录,但没认出它的界面。现在显示的是:',
166
+ '```',
167
+ screen,
168
+ '```',
169
+ '要往这个终端里输入,回复「输入 <内容>」(例如「输入 1」)。其他消息照常发给 Orc。',
170
+ ].join('\n');
171
+ }
172
+ /**
173
+ * DM after the harness rejected the owner's reply (e.g. a wrong code).
174
+ *
175
+ * @param session - Broker session (awaiting input again)
176
+ * @returns Slack mrkdwn text
177
+ */
178
+ export function formatRejectedDm(session) {
179
+ const reason = safeForDm(session.message ?? '', HARNESS_CONSTANTS.RELOGIN.DM_MESSAGE_MAX_CHARS);
180
+ const what = session.method === 'subscription' ? '代码' : '输入';
181
+ return `这个${what}没通过${reason ? `(${reason})` : ''}。再回复一次${what},或者${RETRY_HINT}`;
182
+ }
183
+ /**
184
+ * DM after the login succeeded and the agents were restarted.
185
+ *
186
+ * @param harnessId - Harness
187
+ * @param result - Resume result
188
+ * @returns Slack mrkdwn text
189
+ */
190
+ export function formatSuccessDm(harnessId, result) {
191
+ const count = result.resumed.length;
192
+ let text = `好了:${displayName(harnessId)} 已重新登录,${count} 个 agent 已恢复。`;
193
+ if (result.failed.length > 0) {
194
+ text += ` 没能重启:${result.failed.join('、')}。`;
195
+ }
196
+ return text;
197
+ }
198
+ /**
199
+ * DM after the login failed or timed out (sent once per failure).
200
+ *
201
+ * @param harnessId - Harness
202
+ * @param reason - Broker message (redacted here)
203
+ * @returns Slack mrkdwn text
204
+ */
205
+ export function formatFailureDm(harnessId, reason) {
206
+ const detail = safeForDm(reason ?? '', HARNESS_CONSTANTS.RELOGIN.DM_MESSAGE_MAX_CHARS);
207
+ return `${displayName(harnessId)} 登录没完成${detail ? `:${detail}` : '。'}${RETRY_HINT}`;
208
+ }
209
+ /** Coordinates Slack re-login flows, one per harness. */
210
+ export class HarnessReloginService {
211
+ broker;
212
+ credentials;
213
+ apiKeys;
214
+ checkLoginState;
215
+ getOrcHarness;
216
+ notifier;
217
+ resumer;
218
+ now;
219
+ logger;
220
+ flows = new Map();
221
+ /** Expiry reports are ignored until then (resumed transcripts repeat old errors) */
222
+ quietUntil = new Map();
223
+ /** Last silent API-key recovery per harness */
224
+ silentKeyAt = new Map();
225
+ /** Sessions restarted after a login: screen-sweep reports are ignored until live output reports again */
226
+ mutedScreens = new Set();
227
+ /** Harnesses the status check has seen logged in */
228
+ seenLoggedIn = new Set();
229
+ statusTimer = null;
230
+ statusCheckInFlight = false;
231
+ /**
232
+ * @param deps - Dependencies
233
+ */
234
+ constructor(deps) {
235
+ this.broker = deps.broker;
236
+ this.credentials = deps.credentials;
237
+ this.apiKeys = deps.apiKeys;
238
+ this.checkLoginState = deps.checkLoginState;
239
+ this.getOrcHarness = deps.getOrcHarness;
240
+ this.notifier = deps.notifier ?? null;
241
+ this.resumer = deps.resumer ?? null;
242
+ this.now = deps.now ?? Date.now;
243
+ this.logger = deps.logger ?? SILENT_HARNESS_LOGGER;
244
+ this.broker.on(LOGIN_BROKER_EVENTS.UPDATE, (session) => this.handleUpdate(session));
245
+ this.broker.on(LOGIN_BROKER_EVENTS.FINISHED, (session) => this.handleFinished(session));
246
+ }
247
+ /**
248
+ * Set how the owner is DM'd (the Slack adapter; wired after Slack is up).
249
+ *
250
+ * @param notifier - Notifier, or null
251
+ */
252
+ setNotifier(notifier) {
253
+ this.notifier = notifier;
254
+ }
255
+ /**
256
+ * Set how agents are restarted after a login.
257
+ *
258
+ * @param resumer - Resumer, or null
259
+ */
260
+ setResumer(resumer) {
261
+ this.resumer = resumer;
262
+ }
263
+ /**
264
+ * Report an expired harness login.
265
+ *
266
+ * @param report - Which harness, which session, from where
267
+ * @returns True when the coordinator owns the harness's re-login (the
268
+ * caller must not start its own `/login` or notice); false for a
269
+ * harness Crewly cannot log in (no broker method)
270
+ */
271
+ reportExpiry(report) {
272
+ const { harnessId, sessionName, source } = report;
273
+ if (!hasBrokerLogin(harnessId))
274
+ return false;
275
+ // Right after a login, resumed agents replay their old transcript
276
+ // (including the old error): ignore every report for a while.
277
+ if (this.now() < (this.quietUntil.get(harnessId) ?? 0))
278
+ return true;
279
+ if (sessionName) {
280
+ // A resumed session's screen can keep showing the old error: only
281
+ // new live output counts for it again.
282
+ if (source === 'screen' && this.mutedScreens.has(sessionName))
283
+ return true;
284
+ if (source === 'output')
285
+ this.mutedScreens.delete(sessionName);
286
+ }
287
+ const existing = this.flows.get(harnessId);
288
+ if (existing) {
289
+ if (sessionName)
290
+ existing.stuck.add(sessionName);
291
+ if (existing.phase === 'failed' && this.now() - (existing.lastDmAt ?? 0) >= HARNESS_CONSTANTS.RELOGIN.REMIND_INTERVAL_MS) {
292
+ this.logger.info('Re-login: reminding the owner', { harnessId, source });
293
+ this.restart(existing);
294
+ }
295
+ return true;
296
+ }
297
+ this.logger.warn('Harness login expired — starting a Slack re-login', { harnessId, sessionName, source });
298
+ this.begin(harnessId, new Set(sessionName ? [sessionName] : []));
299
+ return true;
300
+ }
301
+ /**
302
+ * Offer an owner's Slack DM reply to the re-login flows.
303
+ *
304
+ * @param text - Reply text (never logged)
305
+ * @returns True when consumed; the caller must then NOT pass it on as a chat message
306
+ */
307
+ handleOwnerReply(text) {
308
+ if (typeof text !== 'string' || this.flows.size === 0)
309
+ return false;
310
+ if (isRetryKeyword(text)) {
311
+ const flows = [...this.flows.values()];
312
+ const failed = flows.filter((flow) => flow.phase === 'failed');
313
+ const targets = failed.length > 0 ? failed : flows;
314
+ for (const flow of targets)
315
+ this.restart(flow);
316
+ this.logger.info('Re-login restarted on the owner\'s request', { harnessIds: targets.map((flow) => flow.harnessId) });
317
+ return true;
318
+ }
319
+ const reply = unwrapReply(text);
320
+ for (const flow of this.flows.values()) {
321
+ if (flow.phase !== 'running' || !flow.sessionId)
322
+ continue;
323
+ const session = this.readSession(flow.sessionId);
324
+ if (!session || isTerminalLoginState(session.state))
325
+ continue;
326
+ const takesCode = flow.dm === 'link' && session.method === 'subscription' && session.state === 'awaiting_user' && session.needsInput && looksLikeAuthCode(reply);
327
+ const screenText = flow.dm === 'screen' ? stripScreenReplyPrefix(reply) : null;
328
+ const takesScreenReply = screenText !== null &&
329
+ screenText.length > 0 &&
330
+ screenText.length <= HARNESS_CONSTANTS.RELOGIN.SCREEN_REPLY_MAX_LENGTH &&
331
+ !/[\r\n]/.test(screenText);
332
+ if (!takesCode && !takesScreenReply)
333
+ continue;
334
+ try {
335
+ this.broker.input(flow.sessionId, takesCode ? reply : screenText);
336
+ flow.replied = true;
337
+ flow.lastRejection = null;
338
+ this.logger.info('Owner reply typed into the login', { harnessId: flow.harnessId, sessionId: flow.sessionId });
339
+ }
340
+ catch (error) {
341
+ // The session ended between the check and the input. The reply
342
+ // looked like a code: still keep it out of the chat.
343
+ this.logger.warn('Could not type the owner reply into the login', {
344
+ harnessId: flow.harnessId,
345
+ error: error instanceof Error ? error.message : String(error),
346
+ });
347
+ }
348
+ return true;
349
+ }
350
+ return false;
351
+ }
352
+ /**
353
+ * The pending re-login for a harness (shown by `GET /api/harness`).
354
+ *
355
+ * @param harnessId - Harness id
356
+ * @returns The pending re-login, or null when none is running
357
+ */
358
+ getPending(harnessId) {
359
+ const flow = this.flows.get(harnessId);
360
+ if (!flow || flow.phase !== 'running' || !flow.sessionId)
361
+ return null;
362
+ return { harnessId, sessionId: flow.sessionId, startedAt: new Date(flow.startedAt).toISOString() };
363
+ }
364
+ /**
365
+ * Check the orchestrator's harness with its own status command, and
366
+ * report an expiry when it says logged out. Only a harness that was seen
367
+ * logged in, or that agents are running on, counts (a machine that was
368
+ * never logged in is onboarding's job, not a re-login).
369
+ *
370
+ * @returns Resolves when the check is done; never rejects
371
+ */
372
+ async checkOrcHarness() {
373
+ if (this.statusCheckInFlight)
374
+ return;
375
+ this.statusCheckInFlight = true;
376
+ try {
377
+ const orc = await this.getOrcHarness();
378
+ if (!isHarnessId(orc) || !hasBrokerLogin(orc))
379
+ return;
380
+ const state = await this.checkLoginState(orc);
381
+ if (state === 'logged_in') {
382
+ this.seenLoggedIn.add(orc);
383
+ return;
384
+ }
385
+ if (state !== 'logged_out')
386
+ return;
387
+ const running = this.resumer?.listSessions(orc) ?? [];
388
+ if (!this.seenLoggedIn.has(orc) && running.length === 0)
389
+ return;
390
+ this.reportExpiry({ harnessId: orc, sessionName: null, source: 'status' });
391
+ }
392
+ catch (error) {
393
+ this.logger.warn('Harness status check failed', { error: error instanceof Error ? error.message : String(error) });
394
+ }
395
+ finally {
396
+ this.statusCheckInFlight = false;
397
+ }
398
+ }
399
+ /**
400
+ * Start the periodic status check of the orchestrator's harness.
401
+ *
402
+ * @param intervalMs - Cadence (defaults to STATUS_CHECK_INTERVAL_MS)
403
+ */
404
+ start(intervalMs = HARNESS_CONSTANTS.RELOGIN.STATUS_CHECK_INTERVAL_MS) {
405
+ if (this.statusTimer)
406
+ return;
407
+ this.statusTimer = setInterval(() => void this.checkOrcHarness(), intervalMs);
408
+ this.statusTimer.unref?.();
409
+ }
410
+ /** Stop the periodic check and forget every flow's timers. */
411
+ stop() {
412
+ if (this.statusTimer)
413
+ clearInterval(this.statusTimer);
414
+ this.statusTimer = null;
415
+ for (const flow of this.flows.values())
416
+ this.clearScreenTimer(flow);
417
+ }
418
+ /**
419
+ * Start a flow: silent API key if Crewly holds one, else a broker login.
420
+ *
421
+ * @param harnessId - Harness
422
+ * @param stuck - Sessions known to be stuck
423
+ */
424
+ begin(harnessId, stuck) {
425
+ const flow = {
426
+ harnessId,
427
+ phase: 'running',
428
+ sessionId: null,
429
+ startedAt: this.now(),
430
+ stuck,
431
+ dm: 'none',
432
+ lastDmAt: null,
433
+ replied: false,
434
+ lastRejection: null,
435
+ screenTimer: null,
436
+ cancelledByUs: false,
437
+ };
438
+ // Registered synchronously: this is the per-harness debounce.
439
+ this.flows.set(harnessId, flow);
440
+ void this.run(flow);
441
+ }
442
+ /**
443
+ * Body of a flow (async part of {@link begin}).
444
+ *
445
+ * @param flow - The flow
446
+ */
447
+ async run(flow) {
448
+ if (await this.tryStoredApiKey(flow))
449
+ return;
450
+ if (this.flows.get(flow.harnessId) !== flow)
451
+ return;
452
+ const method = getBrokerLoginMethod(flow.harnessId);
453
+ if (!method)
454
+ return;
455
+ let session;
456
+ try {
457
+ session = this.broker.start(flow.harnessId, method.id);
458
+ }
459
+ catch (error) {
460
+ const reason = error instanceof Error ? error.message : String(error);
461
+ this.logger.warn('Re-login: could not start the login', { harnessId: flow.harnessId, error: reason });
462
+ flow.phase = 'failed';
463
+ await this.dm(flow, formatFailureDm(flow.harnessId, `Crewly 没能启动登录(${reason})`));
464
+ return;
465
+ }
466
+ flow.sessionId = session.id;
467
+ flow.startedAt = Date.parse(session.startedAt) || this.now();
468
+ flow.screenTimer = setTimeout(() => void this.sendScreenIfUnrecognised(flow), HARNESS_CONSTANTS.RELOGIN.UNRECOGNISED_SCREEN_MS);
469
+ flow.screenTimer.unref?.();
470
+ this.logger.info('Re-login broker session started', { harnessId: flow.harnessId, sessionId: session.id, method: method.id });
471
+ // The broker may hand back a session that already shows its link.
472
+ if (isTerminalLoginState(session.state))
473
+ this.handleFinished(session);
474
+ else
475
+ this.handleUpdate(session);
476
+ }
477
+ /**
478
+ * Use an API key Crewly holds for the harness, without asking the owner.
479
+ * Not repeated within SILENT_KEY_RETRY_WINDOW_MS: a key that did not
480
+ * help falls back to the phone login.
481
+ *
482
+ * @param flow - The flow
483
+ * @returns True when the key was used and the agents were resumed
484
+ */
485
+ async tryStoredApiKey(flow) {
486
+ const { harnessId } = flow;
487
+ const last = this.silentKeyAt.get(harnessId);
488
+ if (last !== undefined && this.now() - last < HARNESS_CONSTANTS.RELOGIN.SILENT_KEY_RETRY_WINDOW_MS)
489
+ return false;
490
+ try {
491
+ if (harnessId === HARNESS_CONSTANTS.IDS.CLAUDE_CODE) {
492
+ // Agents get ANTHROPIC_API_KEY from the store when they are recreated.
493
+ if (this.credentials.getClaudeCredentialKind() !== 'api_key')
494
+ return false;
495
+ }
496
+ else if (harnessId === HARNESS_CONSTANTS.IDS.CODEX_CLI) {
497
+ const key = this.credentials.read().codex?.openaiApiKey;
498
+ if (!key)
499
+ return false;
500
+ await this.apiKeys.submit(harnessId, key);
501
+ }
502
+ else {
503
+ return false;
504
+ }
505
+ }
506
+ catch (error) {
507
+ this.logger.warn('Re-login: the stored API key did not work, asking the owner instead', {
508
+ harnessId,
509
+ error: error instanceof Error ? error.message : String(error),
510
+ });
511
+ return false;
512
+ }
513
+ this.silentKeyAt.set(harnessId, this.now());
514
+ this.logger.info('Re-login: using the stored API key', { harnessId });
515
+ await this.succeed(flow, false);
516
+ return true;
517
+ }
518
+ /**
519
+ * Broker update: send the link once it is complete, report a rejected reply.
520
+ *
521
+ * @param session - Session snapshot
522
+ */
523
+ handleUpdate(session) {
524
+ const flow = this.flows.get(session.harnessId);
525
+ if (!flow || flow.phase !== 'running' || flow.sessionId !== session.id || isTerminalLoginState(session.state))
526
+ return;
527
+ const linkReady = session.method === 'device' ? Boolean(session.url && session.userCode) : Boolean(session.url);
528
+ if (flow.dm !== 'link' && linkReady) {
529
+ flow.dm = 'link';
530
+ this.clearScreenTimer(flow);
531
+ void this.dm(flow, formatLinkDm(session, this.waitingAgents(flow)));
532
+ return;
533
+ }
534
+ if (flow.replied && session.state === 'awaiting_user' && session.message && session.message !== flow.lastRejection) {
535
+ flow.lastRejection = session.message;
536
+ flow.replied = false;
537
+ void this.dm(flow, formatRejectedDm(session));
538
+ }
539
+ }
540
+ /**
541
+ * Broker session finished.
542
+ *
543
+ * @param session - Final snapshot
544
+ */
545
+ handleFinished(session) {
546
+ const flow = this.flows.get(session.harnessId);
547
+ if (!flow)
548
+ return;
549
+ if (flow.sessionId !== session.id) {
550
+ // The owner logged in another way (web / phone app) while a flow waited.
551
+ if (session.state === 'succeeded') {
552
+ this.clearScreenTimer(flow);
553
+ this.cancelOwnSession(flow);
554
+ void this.succeed(flow, true);
555
+ }
556
+ return;
557
+ }
558
+ if (flow.phase !== 'running')
559
+ return;
560
+ this.clearScreenTimer(flow);
561
+ if (session.state === 'succeeded') {
562
+ void this.succeed(flow, true);
563
+ return;
564
+ }
565
+ flow.phase = 'failed';
566
+ if (flow.cancelledByUs)
567
+ return;
568
+ if (session.state === 'cancelled') {
569
+ // Cancelled on the web or by a shutdown: the owner knows; stay quiet.
570
+ this.logger.info('Re-login cancelled', { harnessId: flow.harnessId, sessionId: session.id });
571
+ return;
572
+ }
573
+ this.logger.warn('Re-login did not finish', { harnessId: flow.harnessId, sessionId: session.id, state: session.state });
574
+ void this.dm(flow, formatFailureDm(flow.harnessId, session.message));
575
+ }
576
+ /**
577
+ * The login is back: restart the stuck agents and tell the owner.
578
+ *
579
+ * @param flow - The flow
580
+ * @param notify - DM the owner (false for the silent API-key path)
581
+ */
582
+ async succeed(flow, notify) {
583
+ if (this.flows.get(flow.harnessId) === flow)
584
+ this.flows.delete(flow.harnessId);
585
+ this.quietUntil.set(flow.harnessId, this.now() + HARNESS_CONSTANTS.RELOGIN.POST_SUCCESS_QUIET_MS);
586
+ this.seenLoggedIn.add(flow.harnessId);
587
+ const sessions = this.waitingAgents(flow);
588
+ let result = { resumed: [], failed: [] };
589
+ if (this.resumer && sessions.length > 0) {
590
+ try {
591
+ result = await this.resumer.resume(sessions);
592
+ }
593
+ catch (error) {
594
+ this.logger.warn('Re-login: resuming agents failed', { error: error instanceof Error ? error.message : String(error) });
595
+ result = { resumed: [], failed: [...sessions] };
596
+ }
597
+ }
598
+ for (const name of result.resumed)
599
+ this.mutedScreens.add(name);
600
+ this.logger.info('Re-login finished', { harnessId: flow.harnessId, resumed: result.resumed.length, failed: result.failed.length });
601
+ if (notify)
602
+ await this.dm(flow, formatSuccessDm(flow.harnessId, result));
603
+ }
604
+ /**
605
+ * Start a flow over (retry keyword or re-reminder).
606
+ *
607
+ * @param flow - The flow to replace
608
+ */
609
+ restart(flow) {
610
+ this.clearScreenTimer(flow);
611
+ this.cancelOwnSession(flow);
612
+ if (this.flows.get(flow.harnessId) === flow)
613
+ this.flows.delete(flow.harnessId);
614
+ this.begin(flow.harnessId, new Set(flow.stuck));
615
+ }
616
+ /**
617
+ * Cancel the flow's live broker session, marking it as cancelled by the
618
+ * coordinator so no failure DM is sent for it.
619
+ *
620
+ * @param flow - The flow
621
+ */
622
+ cancelOwnSession(flow) {
623
+ if (flow.phase !== 'running' || !flow.sessionId)
624
+ return;
625
+ flow.cancelledByUs = true;
626
+ try {
627
+ this.broker.cancel(flow.sessionId);
628
+ }
629
+ catch {
630
+ // Already gone.
631
+ }
632
+ }
633
+ /**
634
+ * After UNRECOGNISED_SCREEN_MS without a link: send the screen as-is.
635
+ *
636
+ * @param flow - The flow
637
+ */
638
+ async sendScreenIfUnrecognised(flow) {
639
+ flow.screenTimer = null;
640
+ if (this.flows.get(flow.harnessId) !== flow || flow.phase !== 'running' || flow.dm !== 'none' || !flow.sessionId)
641
+ return;
642
+ const session = this.readSession(flow.sessionId);
643
+ if (!session || isTerminalLoginState(session.state))
644
+ return;
645
+ flow.dm = 'screen';
646
+ this.logger.warn('Re-login: login screen not recognised, sending it to the owner', { harnessId: flow.harnessId, sessionId: session.id });
647
+ await this.dm(flow, formatScreenDm(session, this.waitingAgents(flow)));
648
+ }
649
+ /**
650
+ * Agents waiting on a flow: the stuck ones, else every session of the harness.
651
+ *
652
+ * @param flow - The flow
653
+ * @returns Session names
654
+ */
655
+ waitingAgents(flow) {
656
+ if (flow.stuck.size > 0)
657
+ return [...flow.stuck];
658
+ try {
659
+ return this.resumer?.listSessions(flow.harnessId) ?? [];
660
+ }
661
+ catch {
662
+ return [];
663
+ }
664
+ }
665
+ /**
666
+ * DM the owner (logs when no notifier is wired; never logs the text).
667
+ *
668
+ * @param flow - The flow
669
+ * @param text - DM text
670
+ */
671
+ async dm(flow, text) {
672
+ flow.lastDmAt = this.now();
673
+ if (!this.notifier) {
674
+ this.logger.warn('Re-login: no Slack DM path to the owner; finish the login from Setup', { harnessId: flow.harnessId });
675
+ return;
676
+ }
677
+ try {
678
+ const delivered = await this.notifier.sendToOwner(text);
679
+ if (!delivered)
680
+ this.logger.warn('Re-login: the owner DM was not delivered', { harnessId: flow.harnessId });
681
+ }
682
+ catch (error) {
683
+ this.logger.warn('Re-login: sending the owner DM failed', {
684
+ harnessId: flow.harnessId,
685
+ error: error instanceof Error ? error.message : String(error),
686
+ });
687
+ }
688
+ }
689
+ /**
690
+ * Read a broker session.
691
+ *
692
+ * @param sessionId - Session id
693
+ * @returns The session, or null when the broker forgot it
694
+ */
695
+ readSession(sessionId) {
696
+ try {
697
+ return this.broker.get(sessionId);
698
+ }
699
+ catch {
700
+ return null;
701
+ }
702
+ }
703
+ /**
704
+ * Clear a flow's unrecognised-screen timer.
705
+ *
706
+ * @param flow - The flow
707
+ */
708
+ clearScreenTimer(flow) {
709
+ if (flow.screenTimer)
710
+ clearTimeout(flow.screenTimer);
711
+ flow.screenTimer = null;
712
+ }
713
+ }
714
+ /** Backend singleton. */
715
+ let instance = null;
716
+ /**
717
+ * The backend's re-login coordinator, bound to the backend harness service
718
+ * (its broker, credentials, API keys and status). Registers itself as the
719
+ * `reloginPending` source of `GET /api/harness`.
720
+ *
721
+ * @returns The singleton
722
+ */
723
+ export function getHarnessReloginService() {
724
+ if (!instance) {
725
+ const harness = getHarnessService();
726
+ const service = new HarnessReloginService({
727
+ broker: harness.broker,
728
+ credentials: getHarnessCredentialsStore(),
729
+ apiKeys: harness.apiKeys,
730
+ checkLoginState: async (harnessId) => {
731
+ const def = getHarnessDefinition(harnessId);
732
+ if (!def)
733
+ return 'unknown';
734
+ const installed = await harness.status.getInstalledInfo(def);
735
+ if (!installed.installed)
736
+ return 'unknown';
737
+ return (await harness.status.getLoginInfo(def, installed.path)).loginState;
738
+ },
739
+ getOrcHarness: () => harness.orc.get(),
740
+ logger: LoggerService.getInstance().createComponentLogger('HarnessRelogin'),
741
+ });
742
+ harness.setReloginPendingProvider((harnessId) => service.getPending(harnessId));
743
+ instance = service;
744
+ }
745
+ return instance;
746
+ }
747
+ /**
748
+ * Replace or clear the singleton (tests).
749
+ *
750
+ * @param service - Service, or null
751
+ */
752
+ export function setHarnessReloginServiceForTesting(service) {
753
+ instance?.stop();
754
+ instance = service;
755
+ }
756
+ //# sourceMappingURL=harness-relogin.service.js.map