@crosshands/cli 0.1.6 → 0.2.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.
Files changed (49) hide show
  1. package/dist/bin.js +3 -3
  2. package/dist/bin.js.map +1 -1
  3. package/dist/broker-host.d.ts +2 -1
  4. package/dist/broker-host.d.ts.map +1 -1
  5. package/dist/broker-host.js +65 -40
  6. package/dist/broker-host.js.map +1 -1
  7. package/dist/broker-result.d.ts +7 -0
  8. package/dist/broker-result.d.ts.map +1 -0
  9. package/dist/broker-result.js +10 -0
  10. package/dist/broker-result.js.map +1 -0
  11. package/dist/index.d.ts +6 -1
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +60 -32
  14. package/dist/index.js.map +1 -1
  15. package/dist/intent/dispatch.d.ts +13 -0
  16. package/dist/intent/dispatch.d.ts.map +1 -0
  17. package/dist/intent/dispatch.js +180 -0
  18. package/dist/intent/dispatch.js.map +1 -0
  19. package/dist/intent/env.d.ts +11 -0
  20. package/dist/intent/env.d.ts.map +1 -0
  21. package/dist/intent/env.js +10 -0
  22. package/dist/intent/env.js.map +1 -0
  23. package/dist/intent/evaluate.d.ts +21 -0
  24. package/dist/intent/evaluate.d.ts.map +1 -0
  25. package/dist/intent/evaluate.js +115 -0
  26. package/dist/intent/evaluate.js.map +1 -0
  27. package/dist/intent/log.d.ts +8 -0
  28. package/dist/intent/log.d.ts.map +1 -0
  29. package/dist/intent/log.js +37 -0
  30. package/dist/intent/log.js.map +1 -0
  31. package/dist/intent/tree.d.ts +13 -0
  32. package/dist/intent/tree.d.ts.map +1 -0
  33. package/dist/intent/tree.js +99 -0
  34. package/dist/intent/tree.js.map +1 -0
  35. package/dist/local-client.d.ts +1 -0
  36. package/dist/local-client.d.ts.map +1 -1
  37. package/dist/local-client.js +23 -15
  38. package/dist/local-client.js.map +1 -1
  39. package/package.json +6 -6
  40. package/src/bin.ts +4 -3
  41. package/src/broker-host.ts +68 -38
  42. package/src/broker-result.ts +14 -0
  43. package/src/index.ts +71 -35
  44. package/src/intent/dispatch.ts +276 -0
  45. package/src/intent/env.ts +13 -0
  46. package/src/intent/evaluate.ts +155 -0
  47. package/src/intent/log.ts +47 -0
  48. package/src/intent/tree.ts +108 -0
  49. package/src/local-client.ts +31 -15
@@ -0,0 +1,276 @@
1
+ import {
2
+ ComputerError,
3
+ createComputerError,
4
+ hasIntentTarget,
5
+ parsePublicInput,
6
+ parsePublicOutput,
7
+ PublicMutationResultSchema,
8
+ PublicSnapshotResultSchema,
9
+ SnapshotResultSchema,
10
+ splitPublicInput,
11
+ toBrokerInput,
12
+ type ComputerOperationName,
13
+ type PublicOperationInput,
14
+ type SnapshotResult,
15
+ type Suggestion
16
+ } from '@crosshands/contract'
17
+
18
+ import { unwrapBrokerResult } from '../broker-result.js'
19
+ import { parseJevEnv, type JevEnv } from './env.js'
20
+ import {
21
+ liveEvaluator,
22
+ namedTargetAllowed,
23
+ suggestionFromAnswers,
24
+ type EvaluateFn
25
+ } from './evaluate.js'
26
+ import type { JevLogger } from './log.js'
27
+ import { clickableMoves, parseTreeMoves } from './tree.js'
28
+
29
+ export type IntentBroker = {
30
+ request(operation: ComputerOperationName, input: unknown): Promise<unknown>
31
+ }
32
+
33
+ export type DispatchOptions = {
34
+ env?: NodeJS.ProcessEnv
35
+ evaluate?: EvaluateFn
36
+ log?: JevLogger
37
+ }
38
+
39
+ const NAMED_GATE = new Set<ComputerOperationName>(['click', 'setValue'])
40
+
41
+ function policyUnavailable() {
42
+ return createComputerError('policy_unavailable', 'Jev could not rank this snapshot').toJSON()
43
+ }
44
+
45
+ function snapshotResultOf(value: unknown): SnapshotResult {
46
+ return SnapshotResultSchema.parse(value)
47
+ }
48
+
49
+ function notAttempted(
50
+ error: ReturnType<typeof policyUnavailable>,
51
+ suggestion?: Suggestion
52
+ ): unknown {
53
+ return PublicMutationResultSchema.parse({
54
+ outcome: { state: 'not_attempted', error },
55
+ ...(suggestion === undefined ? {} : { suggestion })
56
+ })
57
+ }
58
+
59
+ function withSnapshotIssue(
60
+ look: SnapshotResult,
61
+ issue: ReturnType<typeof policyUnavailable>
62
+ ): unknown {
63
+ return PublicSnapshotResultSchema.parse({
64
+ ...look,
65
+ issues: [...look.issues, issue]
66
+ })
67
+ }
68
+
69
+ function contextTokenOf(input: object): string | undefined {
70
+ if (!('contextToken' in input) || typeof input.contextToken !== 'string') return undefined
71
+ return input.contextToken
72
+ }
73
+
74
+ function elementIndexOf(input: object): number | undefined {
75
+ if (!('target' in input)) return undefined
76
+ const target = input.target
77
+ if (target === null || typeof target !== 'object' || !('elementIndex' in target)) return undefined
78
+ return typeof target.elementIndex === 'number' ? target.elementIndex : undefined
79
+ }
80
+
81
+ function boundIndex(operation: ComputerOperationName, suggestion: Suggestion): number | undefined {
82
+ const move = suggestion.move
83
+ if (operation === 'setValue' && move.kind === 'setValue') return move.elementIndex
84
+ if (
85
+ (operation === 'click' || operation === 'scroll' || operation === 'performSecondaryAction') &&
86
+ move.kind === 'click'
87
+ ) {
88
+ return move.elementIndex
89
+ }
90
+ return undefined
91
+ }
92
+
93
+ async function rank(
94
+ look: SnapshotResult,
95
+ goal: string,
96
+ evaluate: EvaluateFn,
97
+ log: JevLogger | undefined,
98
+ app?: string
99
+ ): Promise<Suggestion> {
100
+ const moves = clickableMoves(parseTreeMoves(look.snapshot.treeText))
101
+ if (moves.length === 0) {
102
+ return {
103
+ untrusted: true,
104
+ snapshotId: look.snapshot.id,
105
+ move: { kind: 'blocked', reason: 'no_candidate' }
106
+ }
107
+ }
108
+ const suggestion = suggestionFromAnswers(
109
+ look.snapshot.id,
110
+ moves,
111
+ await evaluate({ goal, ...(app === undefined ? {} : { app }), moves })
112
+ )
113
+ log?.emit({
114
+ kind: 'jev.decision',
115
+ snapshotId: suggestion.snapshotId,
116
+ move: suggestion.move.kind,
117
+ ...(suggestion.move.kind === 'click' ||
118
+ suggestion.move.kind === 'setValue' ||
119
+ suggestion.move.kind === 'secondary' ||
120
+ suggestion.move.kind === 'scroll'
121
+ ? { elementIndex: suggestion.move.elementIndex }
122
+ : {}),
123
+ confidence: suggestion.confidence,
124
+ goal
125
+ })
126
+ return suggestion
127
+ }
128
+
129
+ function evaluatorFor(env: JevEnv, override: EvaluateFn | undefined): EvaluateFn | undefined {
130
+ if (override !== undefined) return override
131
+ if (env.kind !== 'on') return undefined
132
+ return liveEvaluator(env.apiKey)
133
+ }
134
+
135
+ async function lookAgain(request: IntentBroker['request'], token: string): Promise<SnapshotResult> {
136
+ return snapshotResultOf(
137
+ await request('getAppState', { contextToken: token, captureScreenshot: false })
138
+ )
139
+ }
140
+
141
+ async function suggestObserve(
142
+ request: IntentBroker['request'],
143
+ input: PublicOperationInput<'getAppState'>,
144
+ goal: string | undefined,
145
+ env: JevEnv,
146
+ evaluate: EvaluateFn | undefined,
147
+ log: JevLogger | undefined
148
+ ): Promise<unknown> {
149
+ const result = await request('getAppState', toBrokerInput('getAppState', input))
150
+ if (goal === undefined || env.kind === 'off') return result
151
+ const look = snapshotResultOf(result)
152
+ if (env.kind === 'fail_closed' || evaluate === undefined) {
153
+ return withSnapshotIssue(look, policyUnavailable())
154
+ }
155
+ try {
156
+ const app = 'app' in input && typeof input.app === 'string' ? input.app : undefined
157
+ const suggestion = await rank(look, goal, evaluate, log, app)
158
+ return parsePublicOutput('getAppState', { ...look, suggestion })
159
+ } catch {
160
+ return withSnapshotIssue(look, policyUnavailable())
161
+ }
162
+ }
163
+
164
+ async function bindIntentTarget(
165
+ request: IntentBroker['request'],
166
+ operation: ComputerOperationName,
167
+ input: object,
168
+ goal: string | undefined,
169
+ env: JevEnv,
170
+ evaluate: EvaluateFn | undefined,
171
+ log: JevLogger | undefined
172
+ ): Promise<unknown> {
173
+ if (env.kind === 'off') {
174
+ throw createComputerError('invalid_argument', 'intent targeting requires CROSSHANDS_JEV=1')
175
+ }
176
+ if (env.kind === 'fail_closed' || evaluate === undefined || goal === undefined) {
177
+ throw createComputerError('intent_unavailable', 'Jev is enabled but no TypeSafe key is set')
178
+ }
179
+ const token = contextTokenOf(input)
180
+ if (token === undefined) {
181
+ throw createComputerError('invalid_argument', 'intent targeting requires a context token')
182
+ }
183
+ let look: SnapshotResult
184
+ let suggestion: Suggestion
185
+ try {
186
+ look = await lookAgain(request, token)
187
+ suggestion = await rank(look, goal, evaluate, log)
188
+ } catch (cause) {
189
+ if (cause instanceof ComputerError) throw cause
190
+ return notAttempted(policyUnavailable())
191
+ }
192
+ const index = boundIndex(operation, suggestion)
193
+ if (index === undefined) return notAttempted(policyUnavailable(), suggestion)
194
+ const bound = {
195
+ ...splitPublicInput(input).rest,
196
+ contextToken: look.context.token,
197
+ target: { kind: 'element' as const, elementIndex: index }
198
+ }
199
+ const result = await request(operation, toBrokerInput(operation, bound))
200
+ const body = result !== null && typeof result === 'object' ? result : {}
201
+ return parsePublicOutput(operation, {
202
+ ...body,
203
+ resolvedTarget: { kind: 'element', elementIndex: index },
204
+ suggestion
205
+ })
206
+ }
207
+
208
+ async function gateNamedTarget(
209
+ request: IntentBroker['request'],
210
+ input: object,
211
+ goal: string,
212
+ evaluate: EvaluateFn,
213
+ log: JevLogger | undefined
214
+ ): Promise<unknown | undefined> {
215
+ const named = elementIndexOf(input)
216
+ const token = contextTokenOf(input)
217
+ if (token === undefined || named === undefined) return undefined
218
+ try {
219
+ const look = await lookAgain(request, token)
220
+ const suggestion = await rank(look, goal, evaluate, log)
221
+ const picked =
222
+ suggestion.move.kind === 'click' || suggestion.move.kind === 'setValue'
223
+ ? suggestion.move.elementIndex
224
+ : undefined
225
+ if (picked !== named || !namedTargetAllowed(suggestion.confidence)) {
226
+ return notAttempted(
227
+ createComputerError(
228
+ 'goal_mismatch',
229
+ 'Named target does not match the per-call goal'
230
+ ).toJSON(),
231
+ suggestion
232
+ )
233
+ }
234
+ } catch {
235
+ return notAttempted(policyUnavailable())
236
+ }
237
+ return undefined
238
+ }
239
+
240
+ export async function dispatchPublicOperation(
241
+ broker: IntentBroker,
242
+ operation: ComputerOperationName,
243
+ rawInput: unknown,
244
+ options: DispatchOptions = {}
245
+ ): Promise<unknown> {
246
+ const envState = parseJevEnv(options.env)
247
+ const parsed = parsePublicInput(operation, rawInput)
248
+ const { goal } = splitPublicInput(parsed)
249
+ const evaluate = evaluatorFor(envState, options.evaluate)
250
+ const request: IntentBroker['request'] = async (name, input) =>
251
+ unwrapBrokerResult(await broker.request(name, input))
252
+
253
+ if (operation === 'getAppState') {
254
+ return suggestObserve(
255
+ request,
256
+ parsed as PublicOperationInput<'getAppState'>,
257
+ goal,
258
+ envState,
259
+ evaluate,
260
+ options.log
261
+ )
262
+ }
263
+ if (hasIntentTarget(parsed)) {
264
+ return bindIntentTarget(request, operation, parsed, goal, envState, evaluate, options.log)
265
+ }
266
+ if (
267
+ goal !== undefined &&
268
+ envState.kind === 'on' &&
269
+ evaluate !== undefined &&
270
+ NAMED_GATE.has(operation)
271
+ ) {
272
+ const gated = await gateNamedTarget(request, parsed, goal, evaluate, options.log)
273
+ if (gated !== undefined) return gated
274
+ }
275
+ return request(operation, toBrokerInput(operation, parsed))
276
+ }
@@ -0,0 +1,13 @@
1
+ export type JevEnv =
2
+ | { kind: 'off' }
3
+ | { kind: 'fail_closed'; reason: 'missing_key' }
4
+ | { kind: 'on'; apiKey: string }
5
+
6
+ export function parseJevEnv(env: NodeJS.ProcessEnv = process.env): JevEnv {
7
+ if (env.CROSSHANDS_JEV !== '1') return { kind: 'off' }
8
+ const apiKey = env.TYPESAFE_API_KEY
9
+ if (typeof apiKey !== 'string' || apiKey.length === 0) {
10
+ return { kind: 'fail_closed', reason: 'missing_key' }
11
+ }
12
+ return { kind: 'on', apiKey }
13
+ }
@@ -0,0 +1,155 @@
1
+ import type { Suggestion } from '@crosshands/contract'
2
+
3
+ import type { TreeMove } from './tree.js'
4
+
5
+ export type JevAnswers = {
6
+ move: 'click' | 'setValue' | 'wait' | 'done' | 'blocked'
7
+ clickWhich?: string
8
+ setValueWhich?: string
9
+ confidence?: number
10
+ goalMet?: number
11
+ blockedReason?: string
12
+ }
13
+
14
+ export type EvaluateInput = {
15
+ goal: string
16
+ app?: string
17
+ moves: readonly TreeMove[]
18
+ }
19
+
20
+ export type EvaluateFn = (input: EvaluateInput) => Promise<JevAnswers>
21
+
22
+ export const GATE_REFUSE_BELOW = 0.35
23
+
24
+ export function namedTargetAllowed(confidence: number | undefined): boolean {
25
+ if (confidence === undefined) return true
26
+ return confidence >= GATE_REFUSE_BELOW
27
+ }
28
+
29
+ export function suggestionFromAnswers(
30
+ snapshotId: string,
31
+ moves: readonly TreeMove[],
32
+ answers: JevAnswers
33
+ ): Suggestion {
34
+ const byIndex = new Map(moves.map((move) => [String(move.elementIndex), move]))
35
+ if (answers.move === 'wait') {
36
+ return { untrusted: true, snapshotId, move: { kind: 'wait' }, confidence: answers.confidence }
37
+ }
38
+ if (answers.move === 'done') {
39
+ return { untrusted: true, snapshotId, move: { kind: 'done' }, confidence: answers.confidence }
40
+ }
41
+ if (answers.move === 'blocked') {
42
+ return {
43
+ untrusted: true,
44
+ snapshotId,
45
+ move: { kind: 'blocked', reason: answers.blockedReason ?? 'blocked' },
46
+ confidence: answers.confidence
47
+ }
48
+ }
49
+ const raw = answers.move === 'setValue' ? answers.setValueWhich : answers.clickWhich
50
+ const selected = raw === undefined ? undefined : byIndex.get(raw)
51
+ if (selected === undefined) {
52
+ return {
53
+ untrusted: true,
54
+ snapshotId,
55
+ move: { kind: 'blocked', reason: 'no_candidate' },
56
+ confidence: answers.confidence
57
+ }
58
+ }
59
+ return {
60
+ untrusted: true,
61
+ snapshotId,
62
+ move: { kind: answers.move, elementIndex: selected.elementIndex },
63
+ confidence: answers.confidence,
64
+ label: `${selected.role} ${selected.label}`.trim()
65
+ }
66
+ }
67
+
68
+ function criteria(moves: readonly TreeMove[]): Record<string, string> {
69
+ return Object.fromEntries(
70
+ moves.map((move) => [String(move.elementIndex), `${move.role} ${move.label}`.trim()])
71
+ )
72
+ }
73
+
74
+ type SystemOneAnswer = {
75
+ type?: string
76
+ choice?: string
77
+ noul?: number
78
+ confidence?: number
79
+ }
80
+
81
+ export function liveEvaluator(apiKey: string): EvaluateFn {
82
+ return async (input) => {
83
+ const clickable = criteria(input.moves)
84
+ const response = await fetch('https://api.typesafe.ai/v1/systemone', {
85
+ method: 'POST',
86
+ headers: {
87
+ authorization: `Bearer ${apiKey}`,
88
+ 'content-type': 'application/json'
89
+ },
90
+ body: JSON.stringify({
91
+ model: 'jev-latest',
92
+ state: {
93
+ goal: input.goal,
94
+ ...(input.app === undefined ? {} : { app: input.app }),
95
+ clickable
96
+ },
97
+ questions: {
98
+ move: {
99
+ type: 'choice',
100
+ instructions: 'What should we do next for the goal?',
101
+ criteria: {
102
+ click: 'Press one clickable control',
103
+ setValue: 'Pick a field for the caller to fill',
104
+ wait: 'The window is still loading',
105
+ done: 'The goal is already met',
106
+ blocked: 'Stop'
107
+ }
108
+ },
109
+ click_which: {
110
+ type: 'choice',
111
+ instructions: 'If the move is click, which control?',
112
+ criteria: clickable
113
+ },
114
+ setvalue_which: {
115
+ type: 'choice',
116
+ instructions: 'If the move is setValue, which field?',
117
+ criteria: clickable
118
+ },
119
+ goal_met: {
120
+ type: 'noul',
121
+ instructions: 'Is the goal already met?'
122
+ }
123
+ }
124
+ })
125
+ })
126
+ if (!response.ok) {
127
+ throw new Error(`TypeSafe HTTP ${response.status}`)
128
+ }
129
+ const body = (await response.json()) as {
130
+ answers?: Record<string, SystemOneAnswer>
131
+ }
132
+ const answers = body.answers ?? {}
133
+ const move = answers.move?.choice
134
+ if (
135
+ move !== 'click' &&
136
+ move !== 'setValue' &&
137
+ move !== 'wait' &&
138
+ move !== 'done' &&
139
+ move !== 'blocked'
140
+ ) {
141
+ return { move: 'blocked', blockedReason: 'no_candidate' }
142
+ }
143
+ const clickWhich = answers.click_which?.choice
144
+ const setValueWhich = answers.setvalue_which?.choice
145
+ const confidence = answers.move?.confidence ?? answers.click_which?.confidence
146
+ const goalMet = answers.goal_met?.noul
147
+ return {
148
+ move,
149
+ ...(clickWhich === undefined ? {} : { clickWhich }),
150
+ ...(setValueWhich === undefined ? {} : { setValueWhich }),
151
+ ...(confidence === undefined ? {} : { confidence }),
152
+ ...(goalMet === undefined ? {} : { goalMet })
153
+ }
154
+ }
155
+ }
@@ -0,0 +1,47 @@
1
+ import { createHash, randomUUID } from 'node:crypto'
2
+
3
+ import { JsonlFileWriter, diagnosticsDirectory } from '@crosshands/runtime'
4
+
5
+ import { localClientPaths } from '../local-client.js'
6
+ import { parseJevEnv } from './env.js'
7
+
8
+ export type JevLogger = {
9
+ emit(record: Record<string, unknown>): void
10
+ close(): Promise<void>
11
+ }
12
+
13
+ export function hashGoal(goal: string): string {
14
+ return createHash('sha256').update(goal).digest('hex')
15
+ }
16
+
17
+ const OMIT = new Set(['goal', 'apiKey', 'treeText', 'text', 'value', 'TYPESAFE_API_KEY'])
18
+
19
+ export function createJevLogger(
20
+ graphicalSessionId: string,
21
+ env: NodeJS.ProcessEnv = process.env
22
+ ): JevLogger {
23
+ const writer = new JsonlFileWriter({
24
+ directory: diagnosticsDirectory(graphicalSessionId, { env }),
25
+ generation: `jev-${randomUUID()}`
26
+ })
27
+ writer.start()
28
+ return {
29
+ emit(record) {
30
+ const kind = record.kind
31
+ if (typeof kind !== 'string') return
32
+ const goal = record.goal
33
+ const rest: Record<string, unknown> = { ...record, kind, v: 1, ts: new Date().toISOString() }
34
+ for (const key of OMIT) delete rest[key]
35
+ writer.emit({
36
+ ...rest,
37
+ ...(typeof goal === 'string' ? { goalSha256: hashGoal(goal) } : {})
38
+ })
39
+ },
40
+ close: () => writer.close()
41
+ }
42
+ }
43
+
44
+ export function createCliJevLogger(env: NodeJS.ProcessEnv = process.env): JevLogger | undefined {
45
+ if (parseJevEnv(env).kind === 'off') return undefined
46
+ return createJevLogger(localClientPaths().identity.graphicalSessionId, env)
47
+ }
@@ -0,0 +1,108 @@
1
+ export type TreeMove = {
2
+ elementIndex: number
3
+ role: string
4
+ label: string
5
+ line: string
6
+ disabled: boolean
7
+ clickable: boolean
8
+ settable: boolean
9
+ }
10
+
11
+ const ROW_START = /^(\t*)(\d+) (.+)$/
12
+ const MULTI_WORD_ROLES = [
13
+ 'search text field',
14
+ 'text field',
15
+ 'text area',
16
+ 'menu button',
17
+ 'pop up button',
18
+ 'popup button',
19
+ 'radio button',
20
+ 'check box',
21
+ 'combo box',
22
+ 'disclosure triangle',
23
+ 'standard window',
24
+ 'scroll area',
25
+ 'split group',
26
+ 'outline row',
27
+ 'table row'
28
+ ] as const
29
+ const CLICKABLE_ROLE = new Set([
30
+ 'button',
31
+ 'link',
32
+ 'checkbox',
33
+ 'check box',
34
+ 'radio button',
35
+ 'radio',
36
+ 'tab',
37
+ 'menu button',
38
+ 'pop up button',
39
+ 'popup button',
40
+ 'combo box',
41
+ 'disclosure triangle'
42
+ ])
43
+ const SETTABLE_ROLE = new Set(['text field', 'search text field', 'text area', 'combo box'])
44
+
45
+ function isHeaderOrFooter(line: string): boolean {
46
+ if (line.length === 0) return true
47
+ if (line.startsWith('App=')) return true
48
+ if (line.startsWith('Window:')) return true
49
+ if (line.startsWith('The focused UI element')) return true
50
+ if (line.startsWith('No UI element')) return true
51
+ return false
52
+ }
53
+
54
+ function roleAndLabel(body: string): { role: string; label: string } {
55
+ const lower = body.toLowerCase()
56
+ const multi = MULTI_WORD_ROLES.find(
57
+ (role) =>
58
+ lower.startsWith(role) && (body.length === role.length || /[\s,(]/.test(body[role.length]!))
59
+ )
60
+ if (multi !== undefined) {
61
+ return {
62
+ role: body.slice(0, multi.length),
63
+ label: body.slice(multi.length).replace(/^[\s,]+/, '')
64
+ }
65
+ }
66
+ const comma = body.indexOf(',')
67
+ const head = (comma === -1 ? body : body.slice(0, comma)).trim()
68
+ const space = head.indexOf(' ')
69
+ if (space === -1) return { role: head, label: '' }
70
+ return { role: head.slice(0, space), label: head.slice(space + 1).trim() }
71
+ }
72
+
73
+ export function parseTreeMoves(treeText: string): TreeMove[] {
74
+ const rows: { elementIndex: number; body: string }[] = []
75
+ for (const line of treeText.split('\n')) {
76
+ const match = ROW_START.exec(line)
77
+ if (match !== null && match[2] !== undefined && match[3] !== undefined) {
78
+ rows.push({ elementIndex: Number(match[2]), body: match[3] })
79
+ continue
80
+ }
81
+ const previous = rows[rows.length - 1]
82
+ if (previous !== undefined && !isHeaderOrFooter(line)) previous.body += `\n${line}`
83
+ }
84
+ return rows.map((row) => {
85
+ const { role, label } = roleAndLabel(row.body)
86
+ const disabled = /\(disabled\)/.test(row.body)
87
+ const roleKey = role.toLowerCase()
88
+ const settable = /\(settable\)/.test(row.body) || SETTABLE_ROLE.has(roleKey)
89
+ const clickable = !disabled && (CLICKABLE_ROLE.has(roleKey) || settable)
90
+ return {
91
+ elementIndex: row.elementIndex,
92
+ role,
93
+ label,
94
+ line: `${row.elementIndex} ${row.body}`,
95
+ disabled,
96
+ clickable,
97
+ settable
98
+ }
99
+ })
100
+ }
101
+
102
+ export const JEV_CHOICE_CAP = 255
103
+
104
+ export function clickableMoves(moves: readonly TreeMove[]): TreeMove[] {
105
+ const selected = moves.filter((move) => move.clickable)
106
+ if (selected.length <= JEV_CHOICE_CAP) return selected
107
+ return selected.slice(0, JEV_CHOICE_CAP)
108
+ }
@@ -1,12 +1,18 @@
1
1
  import { spawn } from 'node:child_process'
2
- import { createHash } from 'node:crypto'
3
- import { chmod, lstat, mkdir, readFile } from 'node:fs/promises'
2
+ import { lstat, readFile } from 'node:fs/promises'
4
3
  import { homedir } from 'node:os'
5
4
  import { isAbsolute, join, resolve } from 'node:path'
6
5
 
7
6
  import { CONTRACT_VERSIONS, createComputerError } from '@crosshands/contract'
8
- import { LocalControlClient, brokerEndpoint, type LocalControlIdentity } from '@crosshands/runtime'
7
+ import {
8
+ LocalControlClient,
9
+ brokerEndpoint,
10
+ ensurePrivateDirectory,
11
+ graphicalSessionKey,
12
+ type LocalControlIdentity
13
+ } from '@crosshands/runtime'
9
14
 
15
+ import { unwrapBrokerResult } from './broker-result.js'
10
16
  import type { CliBrokerClient } from './index.js'
11
17
 
12
18
  export type LocalClientPaths = {
@@ -29,7 +35,7 @@ export function localClientPaths(): LocalClientPaths {
29
35
  process.env.SECURITYSESSIONID ??
30
36
  process.env.SESSIONNAME ??
31
37
  `interactive:${osIdentity}`
32
- const sessionKey = createHash('sha256').update(graphicalSessionId).digest('hex').slice(0, 12)
38
+ const sessionKey = graphicalSessionKey(graphicalSessionId)
33
39
  const runtimeDirectory = process.env.CROSSHANDS_RUNTIME_DIR ?? defaultRuntimeDirectory(sessionKey)
34
40
  const identity = { osIdentity, graphicalSessionId }
35
41
  return {
@@ -69,14 +75,15 @@ function defaultRuntimeDirectory(sessionKey: string): string {
69
75
  return join(localAppData, 'CrossHands', 'runtime', sessionKey)
70
76
  }
71
77
 
72
- async function prepareRuntimeDirectory(path: string): Promise<void> {
73
- await mkdir(path, { recursive: true, mode: 0o700 })
74
- const info = await lstat(path)
75
- if (!info.isDirectory() || info.isSymbolicLink())
76
- throw createComputerError('provider_unavailable', 'CrossHands runtime path is unsafe')
77
- if (process.getuid !== undefined && info.uid !== process.getuid())
78
- throw createComputerError('provider_unavailable', 'CrossHands runtime path has another owner')
79
- await chmod(path, 0o700)
78
+ function prepareRuntimeDirectory(path: string): void {
79
+ try {
80
+ ensurePrivateDirectory(path)
81
+ } catch (cause) {
82
+ throw createComputerError(
83
+ 'provider_unavailable',
84
+ cause instanceof Error ? cause.message : 'CrossHands directory is unsafe'
85
+ )
86
+ }
80
87
  }
81
88
 
82
89
  async function readSecureToken(path: string): Promise<string> {
@@ -105,12 +112,21 @@ export type ProductionClientOptions = {
105
112
  paths?: LocalClientPaths
106
113
  }
107
114
 
115
+ export function brokerSpawnEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
116
+ const next: NodeJS.ProcessEnv = { ...env }
117
+ delete next.CROSSHANDS_JEV
118
+ for (const key of Object.keys(next)) {
119
+ if (key === 'TYPESAFE_API_KEY' || key.startsWith('TYPESAFE_')) delete next[key]
120
+ }
121
+ return next
122
+ }
123
+
108
124
  function defaultSpawnBroker(entrypoint: string): void {
109
125
  const child = spawn(process.execPath, [entrypoint, 'broker'], {
110
126
  detached: true,
111
127
  stdio: 'ignore',
112
128
  windowsHide: true,
113
- env: process.env
129
+ env: brokerSpawnEnv(process.env)
114
130
  })
115
131
  child.unref()
116
132
  }
@@ -125,7 +141,7 @@ export async function createProductionBrokerClient(
125
141
  options: ProductionClientOptions = {}
126
142
  ): Promise<CliBrokerClient> {
127
143
  const paths = options.paths ?? localClientPaths()
128
- await prepareRuntimeDirectory(paths.runtimeDirectory)
144
+ prepareRuntimeDirectory(paths.runtimeDirectory)
129
145
  try {
130
146
  const control = await connect(paths)
131
147
  return controlAdapter(control)
@@ -165,7 +181,7 @@ export async function createProductionBrokerClient(
165
181
  function controlAdapter(control: LocalControlClient): CliBrokerClient {
166
182
  return {
167
183
  request: async (operation, input) =>
168
- control.request({ operation, input }, { deadlineMs: 30_000 }),
184
+ unwrapBrokerResult(await control.request({ operation, input }, { deadlineMs: 30_000 })),
169
185
  close: () => control.close()
170
186
  }
171
187
  }