typeclaw 0.1.4 → 0.1.5

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/src/tui/client.ts CHANGED
@@ -2,9 +2,19 @@ import type { ClientMessage, ServerMessage } from '@/shared'
2
2
 
3
3
  export type Client = Awaited<ReturnType<typeof createClient>>
4
4
 
5
- export async function createClient(url: string) {
5
+ export type CreateClientOptions = {
6
+ timeoutMs?: number
7
+ }
8
+
9
+ const DEFAULT_CONNECT_TIMEOUT_MS = 30_000
10
+
11
+ export async function createClient(url: string, { timeoutMs = DEFAULT_CONNECT_TIMEOUT_MS }: CreateClientOptions = {}) {
6
12
  const ws = new WebSocket(url)
13
+ const displayUrl = redactUrl(url)
7
14
  const listeners = new Set<(msg: ServerMessage) => void>()
15
+ const closeListeners = new Set<() => void>()
16
+ const errorListeners = new Set<(err: unknown) => void>()
17
+ let closed = false
8
18
  // Buffer messages that arrive before any listener is registered. In-process
9
19
  // connections (typeclaw run's local tui) deliver the first server frame
10
20
  // before the caller has a chance to attach onMessage.
@@ -20,12 +30,43 @@ export async function createClient(url: string) {
20
30
  })
21
31
 
22
32
  ws.addEventListener('close', () => {
33
+ closed = true
23
34
  listeners.clear()
35
+ for (const fn of closeListeners) fn()
36
+ closeListeners.clear()
37
+ })
38
+
39
+ ws.addEventListener('error', (err) => {
40
+ for (const fn of errorListeners) fn(err)
24
41
  })
25
42
 
26
43
  await new Promise<void>((resolve, reject) => {
27
- ws.addEventListener('open', () => resolve(), { once: true })
28
- ws.addEventListener('error', (err) => reject(err), { once: true })
44
+ const timer = setTimeout(() => {
45
+ cleanup()
46
+ ws.close()
47
+ reject(new Error(`timed out connecting to ${displayUrl} after ${timeoutMs}ms`))
48
+ }, timeoutMs)
49
+ const cleanup = () => {
50
+ clearTimeout(timer)
51
+ ws.removeEventListener('open', onOpen)
52
+ ws.removeEventListener('error', onError)
53
+ ws.removeEventListener('close', onClose)
54
+ }
55
+ const onOpen = () => {
56
+ cleanup()
57
+ resolve()
58
+ }
59
+ const onError = (err: unknown) => {
60
+ cleanup()
61
+ reject(err)
62
+ }
63
+ const onClose = () => {
64
+ cleanup()
65
+ reject(new Error(`connection to ${displayUrl} closed before opening`))
66
+ }
67
+ ws.addEventListener('open', onOpen, { once: true })
68
+ ws.addEventListener('error', onError, { once: true })
69
+ ws.addEventListener('close', onClose, { once: true })
29
70
  })
30
71
 
31
72
  return {
@@ -37,9 +78,29 @@ export async function createClient(url: string) {
37
78
  }
38
79
  return () => listeners.delete(fn)
39
80
  },
40
- onClose: (fn: () => void) => ws.addEventListener('close', fn),
41
- onError: (fn: (err: unknown) => void) => ws.addEventListener('error', fn),
81
+ onClose: (fn: () => void) => {
82
+ if (closed) {
83
+ queueMicrotask(fn)
84
+ return () => {}
85
+ }
86
+ closeListeners.add(fn)
87
+ return () => closeListeners.delete(fn)
88
+ },
89
+ onError: (fn: (err: unknown) => void) => {
90
+ errorListeners.add(fn)
91
+ return () => errorListeners.delete(fn)
92
+ },
42
93
  send: (msg: ClientMessage) => ws.send(JSON.stringify(msg)),
43
94
  close: () => ws.close(),
44
95
  }
45
96
  }
97
+
98
+ function redactUrl(url: string): string {
99
+ try {
100
+ const parsed = new URL(url)
101
+ if (parsed.searchParams.has('token')) parsed.searchParams.set('token', '<redacted>')
102
+ return parsed.toString()
103
+ } catch {
104
+ return url
105
+ }
106
+ }
package/src/tui/index.ts CHANGED
@@ -7,11 +7,14 @@ import { colors, editorTheme, markdownTheme } from './theme'
7
7
  export type ClientFactory = (url: string) => Promise<Client>
8
8
  export type TerminalFactory = () => Terminal
9
9
 
10
+ const DEFAULT_HANDSHAKE_TIMEOUT_MS = 30_000
11
+
10
12
  export type TuiOptions = {
11
13
  url: string
12
14
  initialPrompt?: string
13
15
  createClient?: ClientFactory
14
16
  createTerminal?: TerminalFactory
17
+ handshakeTimeoutMs?: number
15
18
  exit?: (code: number) => void
16
19
  }
17
20
 
@@ -20,13 +23,15 @@ export function createTui({
20
23
  initialPrompt,
21
24
  createClient = createClientDefault,
22
25
  createTerminal = () => new ProcessTerminal(),
26
+ handshakeTimeoutMs = DEFAULT_HANDSHAKE_TIMEOUT_MS,
23
27
  exit = process.exit.bind(process),
24
28
  }: TuiOptions) {
25
29
  async function run(): Promise<void> {
26
30
  const terminal = createTerminal()
27
31
  const tui = new TUI(terminal)
32
+ const displayUrl = redactUrl(url)
28
33
 
29
- const status = new Text(colors.dim(`connecting to ${url}...`), 0, 0)
34
+ const status = new Text(colors.dim(`connecting to ${displayUrl}...`), 0, 0)
30
35
  tui.addChild(status)
31
36
  tui.start()
32
37
  tui.requestRender()
@@ -39,14 +44,13 @@ export function createTui({
39
44
  throw err
40
45
  })
41
46
 
42
- const sessionId = await new Promise<string>((resolve) => {
43
- let off: (() => void) | undefined
44
- off = client.onMessage((msg) => {
45
- if (msg.type === 'connected') {
46
- off?.()
47
- resolve(msg.sessionId)
48
- }
49
- })
47
+ const sessionId = await waitForConnected(client, displayUrl, handshakeTimeoutMs).catch((err) => {
48
+ status.setText(colors.red(`connection error: ${err instanceof Error ? err.message : String(err)}`))
49
+ tui.requestRender()
50
+ client.close()
51
+ tui.stop()
52
+ exit(1)
53
+ throw err
50
54
  })
51
55
  status.setText(colors.dim(`session: ${sessionId}`))
52
56
  tui.requestRender()
@@ -223,3 +227,51 @@ export function createTui({
223
227
 
224
228
  return { run }
225
229
  }
230
+
231
+ function redactUrl(url: string): string {
232
+ try {
233
+ const parsed = new URL(url)
234
+ if (parsed.searchParams.has('token')) parsed.searchParams.set('token', '<redacted>')
235
+ return parsed.toString()
236
+ } catch {
237
+ return url
238
+ }
239
+ }
240
+
241
+ async function waitForConnected(client: Client, url: string, timeoutMs: number): Promise<string> {
242
+ return await new Promise<string>((resolve, reject) => {
243
+ const timer = setTimeout(() => {
244
+ cleanup()
245
+ reject(new Error(`timed out waiting for connected message from ${url} after ${timeoutMs}ms`))
246
+ }, timeoutMs)
247
+ const cleanupFns: Array<() => void> = []
248
+ const cleanup = () => {
249
+ clearTimeout(timer)
250
+ for (const fn of cleanupFns.splice(0)) fn()
251
+ }
252
+ cleanupFns.push(
253
+ client.onMessage((msg) => {
254
+ if (msg.type === 'connected') {
255
+ cleanup()
256
+ resolve(msg.sessionId)
257
+ }
258
+ if (msg.type === 'error') {
259
+ cleanup()
260
+ reject(new Error(msg.message))
261
+ }
262
+ }),
263
+ )
264
+ cleanupFns.push(
265
+ client.onClose(() => {
266
+ cleanup()
267
+ reject(new Error(`connection to ${url} closed before the session was ready`))
268
+ }),
269
+ )
270
+ cleanupFns.push(
271
+ client.onError((err) => {
272
+ cleanup()
273
+ reject(err instanceof Error ? err : new Error(`connection to ${url} failed`))
274
+ }),
275
+ )
276
+ })
277
+ }
@@ -731,76 +731,100 @@
731
731
  }
732
732
  }
733
733
  },
734
- "dockerfile": {
734
+ "docker": {
735
735
  "default": {
736
- "ffmpeg": false,
737
- "gh": true,
738
- "python": true,
739
- "tmux": true,
740
- "append": []
736
+ "file": {
737
+ "ffmpeg": false,
738
+ "gh": true,
739
+ "python": true,
740
+ "tmux": true,
741
+ "append": []
742
+ }
741
743
  },
742
744
  "type": "object",
743
745
  "properties": {
744
- "ffmpeg": {
745
- "default": false,
746
- "anyOf": [
747
- {
748
- "type": "boolean"
746
+ "file": {
747
+ "default": {
748
+ "ffmpeg": false,
749
+ "gh": true,
750
+ "python": true,
751
+ "tmux": true,
752
+ "append": []
753
+ },
754
+ "type": "object",
755
+ "properties": {
756
+ "ffmpeg": {
757
+ "default": false,
758
+ "anyOf": [
759
+ {
760
+ "type": "boolean"
761
+ },
762
+ {
763
+ "type": "string",
764
+ "minLength": 1
765
+ }
766
+ ]
749
767
  },
750
- {
751
- "type": "string",
752
- "minLength": 1
753
- }
754
- ]
755
- },
756
- "gh": {
757
- "default": true,
758
- "anyOf": [
759
- {
760
- "type": "boolean"
768
+ "gh": {
769
+ "default": true,
770
+ "anyOf": [
771
+ {
772
+ "type": "boolean"
773
+ },
774
+ {
775
+ "type": "string",
776
+ "minLength": 1
777
+ }
778
+ ]
761
779
  },
762
- {
763
- "type": "string",
764
- "minLength": 1
765
- }
766
- ]
767
- },
768
- "python": {
769
- "default": true,
770
- "type": "boolean"
771
- },
772
- "tmux": {
773
- "default": true,
774
- "anyOf": [
775
- {
780
+ "python": {
781
+ "default": true,
776
782
  "type": "boolean"
777
783
  },
778
- {
779
- "type": "string",
780
- "minLength": 1
784
+ "tmux": {
785
+ "default": true,
786
+ "anyOf": [
787
+ {
788
+ "type": "boolean"
789
+ },
790
+ {
791
+ "type": "string",
792
+ "minLength": 1
793
+ }
794
+ ]
795
+ },
796
+ "append": {
797
+ "default": [],
798
+ "type": "array",
799
+ "items": {
800
+ "type": "string"
801
+ }
781
802
  }
782
- ]
783
- },
784
- "append": {
785
- "default": [],
786
- "type": "array",
787
- "items": {
788
- "type": "string"
789
803
  }
790
804
  }
791
805
  }
792
806
  },
793
- "gitignore": {
807
+ "git": {
794
808
  "default": {
795
- "append": []
809
+ "ignore": {
810
+ "append": []
811
+ }
796
812
  },
797
813
  "type": "object",
798
814
  "properties": {
799
- "append": {
800
- "default": [],
801
- "type": "array",
802
- "items": {
803
- "type": "string"
815
+ "ignore": {
816
+ "default": {
817
+ "append": []
818
+ },
819
+ "type": "object",
820
+ "properties": {
821
+ "append": {
822
+ "default": [],
823
+ "type": "array",
824
+ "items": {
825
+ "type": "string"
826
+ }
827
+ }
804
828
  }
805
829
  }
806
830
  }