@agimon-ai/doompi-runner 0.0.1-alpha.48 → 0.0.1-alpha.49

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agimon-ai/doompi-runner",
3
- "version": "0.0.1-alpha.48",
3
+ "version": "0.0.1-alpha.49",
4
4
  "description": "Supervised shell execution, background process control, and run logs for Pi coding agents.",
5
5
  "keywords": [
6
6
  "ai",
@@ -324,12 +324,12 @@
324
324
  "@xterm/headless": "6.0.0",
325
325
  "hono": "4.13.5",
326
326
  "typebox": "1.3.19",
327
- "@agimon-ai/doompi-extension-contracts": "0.0.1-alpha.47",
328
- "@agimon-ai/doompi-telemetry": "0.0.1-alpha.47",
329
- "@agimon-ai/doompi-ui": "0.0.1-alpha.48",
330
- "@agimon-ai/doompi-web-components": "0.0.1-alpha.8",
331
- "@agimon-ai/doompi-web-contracts": "0.0.1-alpha.11",
332
- "@agimon-ai/doompi-web-security": "0.0.1-alpha.9"
327
+ "@agimon-ai/doompi-extension-contracts": "0.0.1-alpha.48",
328
+ "@agimon-ai/doompi-ui": "0.0.1-alpha.49",
329
+ "@agimon-ai/doompi-web-components": "0.0.1-alpha.9",
330
+ "@agimon-ai/doompi-telemetry": "0.0.1-alpha.48",
331
+ "@agimon-ai/doompi-web-contracts": "0.0.1-alpha.12",
332
+ "@agimon-ai/doompi-web-security": "0.0.1-alpha.10"
333
333
  },
334
334
  "devDependencies": {
335
335
  "@earendil-works/pi-coding-agent": "0.84.4",
@@ -357,14 +357,14 @@
357
357
  }
358
358
  },
359
359
  "optionalDependencies": {
360
- "@agimon-ai/doompi-runner-rmux-darwin-arm64": "0.0.1-alpha.45",
361
- "@agimon-ai/doompi-runner-rmux-linux-arm64": "0.0.1-alpha.45",
362
- "@agimon-ai/doompi-runner-rmux-linux-x64": "0.0.1-alpha.45",
363
- "@agimon-ai/doompi-runner-rtk-darwin-arm64": "0.0.1-alpha.45",
364
- "@agimon-ai/doompi-runner-rtk-darwin-x64": "0.0.1-alpha.45",
365
- "@agimon-ai/doompi-runner-rtk-linux-arm64": "0.0.1-alpha.45",
366
- "@agimon-ai/doompi-runner-rtk-linux-x64": "0.0.1-alpha.45",
367
- "@agimon-ai/doompi-runner-rmux-darwin-x64": "0.0.1-alpha.45"
360
+ "@agimon-ai/doompi-runner-rmux-darwin-arm64": "0.0.1-alpha.46",
361
+ "@agimon-ai/doompi-runner-rmux-darwin-x64": "0.0.1-alpha.46",
362
+ "@agimon-ai/doompi-runner-rmux-linux-x64": "0.0.1-alpha.46",
363
+ "@agimon-ai/doompi-runner-rtk-darwin-arm64": "0.0.1-alpha.46",
364
+ "@agimon-ai/doompi-runner-rtk-darwin-x64": "0.0.1-alpha.46",
365
+ "@agimon-ai/doompi-runner-rtk-linux-arm64": "0.0.1-alpha.46",
366
+ "@agimon-ai/doompi-runner-rtk-linux-x64": "0.0.1-alpha.46",
367
+ "@agimon-ai/doompi-runner-rmux-linux-arm64": "0.0.1-alpha.46"
368
368
  },
369
369
  "engines": {
370
370
  "node": ">=22.19.0"
@@ -1,4 +1,5 @@
1
1
  import {
2
+ AnsiText,
2
3
  Button,
3
4
  FileIcon,
4
5
  MessageItem,
@@ -6,6 +7,7 @@ import {
6
7
  MessageItemHeader,
7
8
  MessageItemStatus,
8
9
  type StatusTone,
10
+ SyntaxText,
9
11
  toolTone,
10
12
  } from '@agimon-ai/doompi-web-components';
11
13
  import type { ToolMessageRenderProps } from '@agimon-ai/doompi-web-contracts';
@@ -29,11 +31,13 @@ const STATUS_TONE: Record<BashStatusTone, StatusTone> = {
29
31
 
30
32
  /**
31
33
  * The bash tool's timeline item, the web half of renderBashCall and
32
- * renderBashResult: the collapsed command and its modifiers in the header;
33
- * the streamed tail while running, then the bounded log tail with its
34
- * one-line summary in the body. A command promoted to a background runner
35
- * offers the same stop the activity dock does, for as long as the runners
36
- * channel reports it running.
34
+ * renderBashResult. Collapsed it is a single header row: the tool name, the
35
+ * command shortened to one line, its modifiers, and the outcome badge, so a
36
+ * transcript of many calls stays scannable. Expanding it opens the body: the
37
+ * command exactly as it was run, then the whole tail the frame carries, each
38
+ * in its own scroll box so one chatty command cannot own the viewport. A
39
+ * command promoted to a background runner offers the same stop the activity
40
+ * dock does, for as long as the runners channel reports it running.
37
41
  */
38
42
  export function BashToolMessage({
39
43
  sessionId,
@@ -58,30 +62,35 @@ export function BashToolMessage({
58
62
  runners.store,
59
63
  (state) => runnerId !== undefined && runners.select(state, sessionId).stopRequested.includes(runnerId),
60
64
  );
61
- const command = typeof args.command === 'string' ? formatBashCommand(args.command) : '';
65
+ const rawCommand = typeof args.command === 'string' ? args.command : '';
66
+ const command = formatBashCommand(rawCommand);
62
67
  const flags = formatBashFlags(args);
63
- const collapsed = bashResultView({ details: result?.details, output, expanded: false, isPartial: running, isError });
64
68
  const stoppable = sessionId !== null && runnerId !== undefined && run !== undefined && run.exit === undefined;
65
69
 
66
70
  return (
67
- <MessageItem
68
- tone={toolTone({ running, isError })}
69
- expandable={collapsed.hidden > 0 || details.logPath !== undefined}
70
- >
71
+ <MessageItem tone={toolTone({ running, isError })} expandable>
71
72
  {({ expanded }) => {
72
- const view = expanded
73
- ? bashResultView({ details: result?.details, output, expanded: true, isPartial: running, isError })
74
- : collapsed;
73
+ // Nothing is shown until the card is open, so the body always reads the
74
+ // unclipped view; the collapsed state has no lines to bound.
75
+ const view = bashResultView({
76
+ details: result?.details,
77
+ output,
78
+ expanded: true,
79
+ isPartial: running,
80
+ isError,
81
+ });
75
82
  return (
76
83
  <>
77
84
  <MessageItemHeader title="bash">
78
85
  <span data-testid="tool-call-bash" className="flex min-w-0 flex-1 items-center gap-2">
79
86
  <span className="min-w-0 flex-1 truncate font-mono text-doom-text">{command}</span>
80
- {flags.length > 0 ? <span className="shrink-0 text-doom-faint">· {flags.join(' · ')}</span> : null}
87
+ {flags.length > 0 ? (
88
+ <span className="hidden shrink-0 text-doom-faint sm:inline">· {flags.join(' · ')}</span>
89
+ ) : null}
81
90
  </span>
82
- {/* The card only ever shows a bounded tail, and expanding it
83
- shows a longer bounded tail. The whole log is a click away
84
- whether or not the card is open. */}
91
+ {/* The log and the stop live in the header, not the body: a
92
+ collapsed row still has to reach the whole log and still has
93
+ to be able to stop a run it promoted to the background. */}
85
94
  {run !== undefined && sessionId !== null ? (
86
95
  <Button
87
96
  variant="outline"
@@ -98,54 +107,66 @@ export function BashToolMessage({
98
107
  log
99
108
  </Button>
100
109
  ) : null}
110
+ {stoppable ? (
111
+ <Button
112
+ size="xs"
113
+ variant={stopping ? 'outline' : 'danger-outline'}
114
+ disabled={stopping}
115
+ data-testid="tool-result-bash-stop"
116
+ data-stopping={stopping}
117
+ title={
118
+ stopping ? 'stop requested; the runner reports its own exit' : 'ask the runtime to stop this runner'
119
+ }
120
+ onClick={(event) => {
121
+ event.stopPropagation();
122
+ if (sessionId !== null && runnerId !== undefined)
123
+ requestRunnerStop(sendSessionFrame, sessionId, runnerId);
124
+ }}
125
+ className="shrink-0 px-1.5 text-[9px] font-bold"
126
+ >
127
+ {stopping ? 'stopping…' : 'stop'}
128
+ </Button>
129
+ ) : null}
101
130
  </MessageItemHeader>
102
- {view.lines.length > 0 || view.status !== null || stoppable ? (
103
- <MessageItemBody data-testid="tool-result-bash" className="flex flex-col gap-1">
131
+ {expanded ? (
132
+ <MessageItemBody data-testid="tool-result-bash" className="flex flex-col gap-2">
133
+ {/* The header had to shorten the command to one row; this is
134
+ the command as it was actually run, wrapped rather than
135
+ clipped and coloured as the shell it is. */}
136
+ {rawCommand.length > 0 ? (
137
+ <SyntaxText
138
+ data-testid="tool-result-bash-command"
139
+ grammar="shell"
140
+ text={rawCommand}
141
+ className="max-h-40 overflow-auto text-doom-text"
142
+ />
143
+ ) : null}
144
+ {/* The tail arrives with the colours the command wrote: the
145
+ log keeps them, and only the model's copy is flattened. */}
104
146
  {view.lines.length > 0 ? (
105
- <pre
106
- className={`${expanded ? 'whitespace-pre-wrap break-words' : 'overflow-hidden whitespace-pre'} font-mono text-doom-dim`}
107
- >
108
- {view.lines.join('\n')}
109
- </pre>
147
+ <AnsiText
148
+ data-testid="tool-result-bash-output"
149
+ text={view.lines.join('\n')}
150
+ className="max-h-96 overflow-auto whitespace-pre-wrap break-words [overflow-wrap:anywhere] border-doom-border-soft border-t pt-2 font-mono text-doom-dim"
151
+ />
110
152
  ) : null}
111
153
  {view.status ? (
112
154
  <MessageItemStatus
113
155
  data-testid="tool-result-bash-status"
114
156
  tone={STATUS_TONE[view.status.tone]}
115
157
  glyph={view.status.glyph}
158
+ className="min-w-0"
116
159
  >
117
- {view.status.text}
118
- {view.hidden > 0 ? ` · ${String(view.hidden)} more line(s) above` : ''}
160
+ <span className="break-words">{view.status.text}</span>
119
161
  </MessageItemStatus>
120
162
  ) : null}
121
- {expanded && details.logPath !== undefined ? (
163
+ {details.logPath !== undefined ? (
122
164
  // The tail is bounded by the runner; the whole log only ever exists on
123
165
  // disk, so the item names the file rather than pretending this is all.
124
- <span data-testid="tool-result-bash-log" className="truncate text-doom-faint">
166
+ <span data-testid="tool-result-bash-log" className="min-w-0 break-all text-doom-faint">
125
167
  full log · <span className="text-doom-dim">{details.logPath}</span>
126
168
  </span>
127
169
  ) : null}
128
- {stoppable ? (
129
- <Button
130
- size="xs"
131
- variant={stopping ? 'outline' : 'danger-outline'}
132
- disabled={stopping}
133
- data-testid="tool-result-bash-stop"
134
- data-stopping={stopping}
135
- title={
136
- stopping
137
- ? 'stop requested; the runner reports its own exit'
138
- : 'ask the runtime to stop this runner'
139
- }
140
- onClick={() => {
141
- if (sessionId !== null && runnerId !== undefined)
142
- requestRunnerStop(sendSessionFrame, sessionId, runnerId);
143
- }}
144
- className="self-start px-1.5 text-[8px] font-bold"
145
- >
146
- {stopping ? 'stopping…' : 'stop'}
147
- </Button>
148
- ) : null}
149
170
  </MessageItemBody>
150
171
  ) : null}
151
172
  </>
@@ -1,4 +1,4 @@
1
- import { Button, Input, SearchIcon, StatusBadge, type StatusTone } from '@agimon-ai/doompi-web-components';
1
+ import { AnsiText, Button, Input, SearchIcon, StatusBadge, type StatusTone } from '@agimon-ai/doompi-web-components';
2
2
  import type { TransientTab, WebPluginSlotProps } from '@agimon-ai/doompi-web-contracts';
3
3
  import { useStore } from '@tanstack/react-store';
4
4
  import { useCallback, useEffect, useRef, useState } from 'react';
@@ -249,9 +249,10 @@ export function RunnerLogPanel({ sessionId, runId, sendSessionFrame }: WebPlugin
249
249
  ) : lines.length === 0 ? (
250
250
  <p className="text-[10px] text-doom-faint">{filtering ? 'nothing matched' : 'the log is empty'}</p>
251
251
  ) : (
252
- <pre className="whitespace-pre-wrap break-words font-mono text-[10px] leading-[1.6] text-doom-dim">
253
- {lines.join('\n')}
254
- </pre>
252
+ <AnsiText
253
+ text={lines.join('\n')}
254
+ className="whitespace-pre-wrap break-words font-mono text-[10px] leading-[1.6] text-doom-dim"
255
+ />
255
256
  )}
256
257
  <div ref={bottom} />
257
258
  </div>
@@ -6,6 +6,7 @@ import type { RunnerRunView } from '../types/webRunners.ts';
6
6
  import { formatRunnerUptime } from './format.ts';
7
7
  import { runnerLogTab } from './RunnerLogPanel.tsx';
8
8
  import { requestRunnerStop, runners } from './runnersStore.ts';
9
+ import { useRunnerTail } from './runnerTail.ts';
9
10
 
10
11
  const TICK_MS = 10_000;
11
12
 
@@ -39,69 +40,102 @@ export function RunnersActivitySection({ sessionId, sendSessionFrame, openTransi
39
40
 
40
41
  return (
41
42
  <div data-testid="activity-runner-runs" className="flex flex-col gap-0.5">
42
- {running.map((run: RunnerRunView) => {
43
- const stopRequested = session.stopRequested.includes(run.id);
44
- return (
43
+ {running.map((run: RunnerRunView) => (
44
+ <RunnerRow
45
+ key={run.id}
46
+ run={run}
47
+ now={now}
48
+ stopRequested={session.stopRequested.includes(run.id)}
49
+ sessionId={sessionId}
50
+ sendSessionFrame={sendSessionFrame}
51
+ openTransientTab={openTransientTab}
52
+ />
53
+ ))}
54
+ </div>
55
+ );
56
+ }
57
+
58
+ /**
59
+ * One running runner: its name, how long it has been up, a stop control, and
60
+ * one line of what it is doing. Its own component because the tail line is a
61
+ * subscription per row, and a row that scrolls out of the dock closes it.
62
+ */
63
+ function RunnerRow({
64
+ run,
65
+ now,
66
+ stopRequested,
67
+ sessionId,
68
+ sendSessionFrame,
69
+ openTransientTab,
70
+ }: {
71
+ run: RunnerRunView;
72
+ now: number;
73
+ stopRequested: boolean;
74
+ } & Pick<WebPluginSlotProps, 'sessionId' | 'sendSessionFrame' | 'openTransientTab'>) {
75
+ const tail = useRunnerTail(sessionId, run.id, run.state === 'running');
76
+ return (
77
+ <Button
78
+ variant="ghost"
79
+ size="card"
80
+ data-testid={`activity-runner-${run.id}`}
81
+ data-runner-tone="running"
82
+ title="open this runner's log"
83
+ onClick={() => {
84
+ if (sessionId === null) return;
85
+ openTransientTab(runnerLogTab(run));
86
+ }}
87
+ className="min-w-0 gap-0.5 rounded-[5px] px-1 py-1 hover:bg-doom-panel"
88
+ >
89
+ <span className="flex min-w-0 items-center gap-1.5">
90
+ <Dot tone="yellow" pulse />
91
+ <span className="min-w-0 flex-1 truncate text-left text-[10px] font-bold text-doom-hi">{run.name}</span>
92
+ <span className="shrink-0 text-[9px] text-doom-faint">
93
+ {formatRunnerUptime(run.startedAt, now)}
94
+ {run.interactive ? ' · tty' : ''}
95
+ </span>
96
+ {sessionId !== null ? (
45
97
  <Button
46
- key={run.id}
47
- variant="ghost"
48
- size="card"
49
- data-testid={`activity-runner-${run.id}`}
50
- data-runner-tone="running"
51
- title="open this runner's log"
52
- onClick={() => {
53
- if (sessionId === null) return;
54
- openTransientTab(runnerLogTab(run));
55
- }}
56
- className="min-w-0 gap-0.5 rounded-[5px] px-1 py-1 hover:bg-doom-panel"
98
+ asChild
99
+ variant={stopRequested ? 'outline' : 'danger-outline'}
100
+ size="xs"
101
+ data-testid={`activity-runner-stop-${run.id}`}
102
+ data-stopping={stopRequested}
103
+ title={
104
+ stopRequested ? 'stop requested; the runner reports its own exit' : 'ask the runtime to stop this runner'
105
+ }
106
+ className="px-1.5 text-[8px] font-bold"
57
107
  >
58
- <span className="flex min-w-0 items-center gap-1.5">
59
- <Dot tone="yellow" pulse />
60
- <span className="min-w-0 flex-1 truncate text-left text-[10px] font-bold text-doom-hi">{run.name}</span>
61
- <span className="shrink-0 text-[9px] text-doom-faint">
62
- {formatRunnerUptime(run.startedAt, now)}
63
- {run.interactive ? ' · tty' : ''}
64
- </span>
65
- {sessionId !== null ? (
66
- <Button
67
- asChild
68
- variant={stopRequested ? 'outline' : 'danger-outline'}
69
- size="xs"
70
- data-testid={`activity-runner-stop-${run.id}`}
71
- data-stopping={stopRequested}
72
- title={
73
- stopRequested
74
- ? 'stop requested; the runner reports its own exit'
75
- : 'ask the runtime to stop this runner'
76
- }
77
- className="px-1.5 text-[8px] font-bold"
78
- >
79
- {/* The row is already a button, so the stop control lends it
80
- the primitive's styling rather than nesting a second one. */}
81
- <span
82
- role="button"
83
- tabIndex={stopRequested ? -1 : 0}
84
- aria-disabled={stopRequested}
85
- onClick={(event) => {
86
- event.stopPropagation();
87
- if (!stopRequested) requestRunnerStop(sendSessionFrame, sessionId, run.id);
88
- }}
89
- onKeyDown={(event) => {
90
- if (event.key !== 'Enter' && event.key !== ' ') return;
91
- event.preventDefault();
92
- event.stopPropagation();
93
- if (!stopRequested) requestRunnerStop(sendSessionFrame, sessionId, run.id);
94
- }}
95
- >
96
- {stopRequested ? 'stopping…' : 'stop'}
97
- </span>
98
- </Button>
99
- ) : null}
108
+ {/* The row is already a button, so the stop control lends it
109
+ the primitive's styling rather than nesting a second one. */}
110
+ <span
111
+ role="button"
112
+ tabIndex={stopRequested ? -1 : 0}
113
+ aria-disabled={stopRequested}
114
+ onClick={(event) => {
115
+ event.stopPropagation();
116
+ if (!stopRequested) requestRunnerStop(sendSessionFrame, sessionId, run.id);
117
+ }}
118
+ onKeyDown={(event) => {
119
+ if (event.key !== 'Enter' && event.key !== ' ') return;
120
+ event.preventDefault();
121
+ event.stopPropagation();
122
+ if (!stopRequested) requestRunnerStop(sendSessionFrame, sessionId, run.id);
123
+ }}
124
+ >
125
+ {stopRequested ? 'stopping…' : 'stop'}
100
126
  </span>
101
- <span className="truncate pl-3 text-left text-[9px] text-doom-faint">{run.command}</span>
102
127
  </Button>
103
- );
104
- })}
105
- </div>
128
+ ) : null}
129
+ </span>
130
+ {/* What it is doing, falling back to what it was asked to do until the
131
+ first line of output arrives. */}
132
+ <span
133
+ data-testid={`activity-runner-detail-${run.id}`}
134
+ data-detail={tail === undefined ? 'command' : 'tail'}
135
+ className="truncate pl-3 text-left text-[9px] text-doom-faint"
136
+ >
137
+ {tail ?? run.command}
138
+ </span>
139
+ </Button>
106
140
  );
107
141
  }
@@ -0,0 +1,82 @@
1
+ import { ansiSpans } from '@agimon-ai/doompi-web-components';
2
+ import { useEffect, useState } from 'react';
3
+ import { fetchRunnerLog, followRunnerLog, type RunnerLogFollow } from './logApi.ts';
4
+
5
+ /**
6
+ * The newest line a running runner has written, for a row that has one line to
7
+ * spend.
8
+ *
9
+ * The dock answers "what is happening", and a command that has not changed
10
+ * since it started answers "what was asked". The last line of the log is the
11
+ * closest thing to progress the cockpit can show without opening the log, so a
12
+ * row that is still running trades its command for it.
13
+ *
14
+ * The line comes from the same two calls the log panel makes: read the tail,
15
+ * then follow the stream from exactly where that read ended. Nothing new goes
16
+ * on the wire, and a runner nobody is looking at is not followed, because the
17
+ * stream lives and dies with the row.
18
+ */
19
+
20
+ /** Enough lines that a trailing blank, or a final progress redraw, is not the whole answer. */
21
+ const TAIL_REQUEST_LINES = 5;
22
+
23
+ /**
24
+ * The last line worth showing, as plain text.
25
+ *
26
+ * Blank lines are skipped, because a log that ends with a newline would
27
+ * otherwise report nothing at all. Colour is dropped rather than rendered: the
28
+ * row is nine pixels of faint text beside a name and a stop control, and a
29
+ * green tick from someone else's palette in the middle of it reads as damage.
30
+ */
31
+ export function plainTailLine(lines: readonly string[]): string | undefined {
32
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
33
+ const text = ansiSpans(lines[index] ?? '')
34
+ .map((span) => span.text)
35
+ .join('')
36
+ .trim();
37
+ if (text.length > 0) return text;
38
+ }
39
+ return undefined;
40
+ }
41
+
42
+ /**
43
+ * The newest log line of a running runner, or undefined for a runner that is
44
+ * finished, unreachable, or has not written anything yet. The caller decides
45
+ * what to show instead; this hook never invents a placeholder.
46
+ */
47
+ export function useRunnerTail(sessionId: string | null, runId: string, running: boolean): string | undefined {
48
+ // Kept with the run it belongs to, so a row that is reused for another
49
+ // runner shows nothing rather than the previous runner's output.
50
+ const [tail, setTail] = useState<{ runId: string; text: string } | undefined>(undefined);
51
+
52
+ useEffect(() => {
53
+ if (!running || sessionId === null) return;
54
+ let live = true;
55
+ let follow: RunnerLogFollow | undefined;
56
+ const controller = new AbortController();
57
+ void fetchRunnerLog(sessionId, runId, { lines: TAIL_REQUEST_LINES }, controller.signal).then((result) => {
58
+ // A log the hub cannot serve leaves the row on its command, which is
59
+ // still true; the log tab is where an unreachable runner is diagnosed.
60
+ if (!live || 'error' in result) return;
61
+ const initial = plainTailLine(result.slice.text.split('\n'));
62
+ if (initial !== undefined) setTail({ runId, text: initial });
63
+ if (!result.slice.running) return;
64
+ follow = followRunnerLog(sessionId, runId, result.slice.fileSize, {
65
+ onEvent: (event) => {
66
+ const next = plainTailLine(event.lines);
67
+ if (live && next !== undefined) setTail({ runId, text: next });
68
+ },
69
+ // A dropped stream freezes the line at the last one that arrived. The
70
+ // row is a glance, and reconnecting it would cost more than it says.
71
+ onError: () => undefined,
72
+ });
73
+ });
74
+ return () => {
75
+ live = false;
76
+ controller.abort();
77
+ follow?.close();
78
+ };
79
+ }, [sessionId, runId, running]);
80
+
81
+ return running && tail?.runId === runId ? tail.text : undefined;
82
+ }
@@ -19,5 +19,5 @@
19
19
  "forceConsistentCasingInFileNames": true,
20
20
  "types": ["node"]
21
21
  },
22
- "include": [".", "../types", "../exports/webClient.ts"]
22
+ "include": [".", "../types", "../exports/webClient.ts", "../../tests/web"]
23
23
  }